1use std::path::Path;
7
8#[cfg(any(feature = "postgres-sync", feature = "tokio-postgres"))]
9use crate::config::PostgresCreds;
10use crate::config::{Credentials, Dialect, Extension, IntrospectCasing};
11use crate::error::CliError;
12use crate::output;
13#[cfg(any(
14 test,
15 feature = "rusqlite",
16 feature = "libsql",
17 feature = "turso",
18 feature = "postgres-sync",
19 feature = "tokio-postgres",
20 feature = "d1-http",
21))]
22use drizzle_migrations::Migrations;
23use drizzle_migrations::schema::Snapshot;
24
25#[cfg(feature = "d1-http")]
26mod d1_http;
27
28#[derive(Debug)]
30pub struct MigrationResult {
31 pub applied_count: usize,
33 pub applied_migrations: Vec<String>,
35}
36
37#[derive(Debug, Clone)]
39pub struct MigrationPlan {
40 pub applied_count: usize,
42 pub pending_count: usize,
44 pub pending_migrations: Vec<String>,
46 pub pending_statements: usize,
48}
49
50#[cfg(any(
51 test,
52 feature = "rusqlite",
53 feature = "libsql",
54 feature = "turso",
55 feature = "postgres-sync",
56 feature = "tokio-postgres",
57 feature = "d1-http",
58))]
59#[derive(Debug, Clone)]
60pub(crate) struct AppliedMigrationRecord {
61 pub(crate) hash: String,
62 pub(crate) name: String,
63}
64
65#[derive(Debug, Clone)]
67pub struct PushPlan {
68 pub sql_statements: Vec<String>,
69 pub warnings: Vec<String>,
70 pub destructive: bool,
71}
72
73#[derive(Debug, Clone, Default)]
75pub struct SnapshotFilters {
76 pub tables: Option<Vec<String>>,
77 pub schemas: Option<Vec<String>>,
78 pub extensions: Option<Vec<Extension>>,
79}
80
81impl SnapshotFilters {
82 const fn is_empty(&self) -> bool {
83 self.tables.is_none() && self.schemas.is_none() && self.extensions.is_none()
84 }
85}
86
87pub fn plan_push(
94 credentials: &Credentials,
95 dialect: Dialect,
96 desired: &Snapshot,
97 breakpoints: bool,
98 filters: &SnapshotFilters,
99) -> Result<PushPlan, CliError> {
100 let mut current = introspect_database(credentials, dialect)?.snapshot;
101 apply_snapshot_filters(&mut current, dialect, filters)?;
102 let (sql_statements, warnings) = generate_push_sql(¤t, desired, breakpoints)?;
103 let destructive = sql_statements.iter().any(|s| is_destructive_statement(s));
104
105 Ok(PushPlan {
106 sql_statements,
107 warnings,
108 destructive,
109 })
110}
111
112pub fn apply_push(
120 credentials: &Credentials,
121 dialect: Dialect,
122 plan: &PushPlan,
123 force: bool,
124) -> Result<(), CliError> {
125 if plan.sql_statements.is_empty() {
126 return Ok(());
127 }
128
129 if plan.destructive && !force {
130 let confirmed = confirm_destructive()?;
131 if !confirmed {
132 return Ok(());
133 }
134 }
135
136 execute_statements(credentials, dialect, &plan.sql_statements)
137}
138
139#[allow(unused_variables)] pub fn plan_migrations(
151 credentials: &Credentials,
152 dialect: Dialect,
153 migrations_dir: &Path,
154 migrations_table: &str,
155 migrations_schema: &str,
156) -> Result<MigrationPlan, CliError> {
157 #[cfg(any(
158 feature = "rusqlite",
159 feature = "libsql",
160 feature = "turso",
161 feature = "postgres-sync",
162 feature = "tokio-postgres",
163 feature = "d1-http",
164 ))]
165 let set = load_migration_set(dialect, migrations_dir, migrations_table, migrations_schema)?;
166
167 match credentials {
168 #[cfg(feature = "rusqlite")]
169 Credentials::Sqlite { path } => inspect_sqlite_migrations(&set, path),
170
171 #[cfg(not(feature = "rusqlite"))]
172 Credentials::Sqlite { .. } => Err(CliError::MissingDriver {
173 dialect: "SQLite",
174 feature: "rusqlite",
175 }),
176
177 #[cfg(any(feature = "libsql", feature = "turso"))]
178 Credentials::Turso { url, auth_token } => {
179 if is_local_libsql(url) {
180 #[cfg(feature = "libsql")]
181 {
182 inspect_libsql_local_migrations(&set, url)
183 }
184 #[cfg(not(feature = "libsql"))]
185 {
186 Err(CliError::MissingDriver {
187 dialect: "LibSQL (local)",
188 feature: "libsql",
189 })
190 }
191 } else {
192 #[cfg(feature = "turso")]
193 {
194 inspect_turso_migrations(&set, url, auth_token.as_deref())
195 }
196 #[cfg(not(feature = "turso"))]
197 {
198 let _ = auth_token;
199 Err(CliError::MissingDriver {
200 dialect: "Turso (remote)",
201 feature: "turso",
202 })
203 }
204 }
205 }
206
207 #[cfg(all(not(feature = "turso"), not(feature = "libsql")))]
208 Credentials::Turso { .. } => Err(CliError::MissingDriver {
209 dialect: "Turso",
210 feature: "turso or libsql",
211 }),
212
213 Credentials::Postgres(creds) => {
214 let _ = creds;
215 core::cfg_select! {
216 feature = "postgres-sync" => inspect_postgres_sync_migrations(&set, creds),
217 feature = "tokio-postgres" => inspect_postgres_async_migrations(&set, creds),
218 _ => Err(CliError::MissingDriver {
219 dialect: "PostgreSQL",
220 feature: "postgres-sync or tokio-postgres",
221 }),
222 }
223 }
224
225 #[cfg(feature = "d1-http")]
226 Credentials::D1 {
227 account_id,
228 database_id,
229 token,
230 } => d1_http::inspect_migrations(&set, account_id, database_id, token),
231
232 #[cfg(not(feature = "d1-http"))]
233 Credentials::D1 { .. } => Err(CliError::MissingDriver {
234 dialect: "Cloudflare D1 (HTTP)",
235 feature: "d1-http",
236 }),
237
238 Credentials::AwsDataApi { .. } => Err(CliError::UnsupportedForDriver {
239 operation: "Migration planning against AWS Data API",
240 driver: "aws-data-api",
241 hint: "AWS RDS Data API schema ops are not yet wired into this CLI. For now, \
242 run the generated SQL with `aws rds-data execute-statement --sql=...` \
243 (or use tokio-postgres with a temporary direct connection).",
244 }),
245 }
246}
247
248pub fn verify_migrations(
256 credentials: &Credentials,
257 dialect: Dialect,
258 migrations_dir: &Path,
259 migrations_table: &str,
260 migrations_schema: &str,
261) -> Result<MigrationPlan, CliError> {
262 plan_migrations(
263 credentials,
264 dialect,
265 migrations_dir,
266 migrations_table,
267 migrations_schema,
268 )
269}
270
271#[allow(unused_variables)] pub fn run_migrations(
281 credentials: &Credentials,
282 dialect: Dialect,
283 migrations_dir: &Path,
284 migrations_table: &str,
285 migrations_schema: &str,
286) -> Result<MigrationResult, CliError> {
287 #[cfg(any(
288 feature = "rusqlite",
289 feature = "libsql",
290 feature = "turso",
291 feature = "postgres-sync",
292 feature = "tokio-postgres",
293 feature = "d1-http",
294 ))]
295 let set = load_migration_set(dialect, migrations_dir, migrations_table, migrations_schema)?;
296
297 match credentials {
298 #[cfg(feature = "rusqlite")]
299 Credentials::Sqlite { path } => run_sqlite_migrations(&set, path),
300
301 #[cfg(not(feature = "rusqlite"))]
302 Credentials::Sqlite { .. } => Err(CliError::MissingDriver {
303 dialect: "SQLite",
304 feature: "rusqlite",
305 }),
306
307 #[cfg(any(feature = "libsql", feature = "turso"))]
308 Credentials::Turso { url, auth_token } => {
309 if is_local_libsql(url) {
310 #[cfg(feature = "libsql")]
311 {
312 run_libsql_local_migrations(&set, url)
313 }
314 #[cfg(not(feature = "libsql"))]
315 {
316 Err(CliError::MissingDriver {
317 dialect: "LibSQL (local)",
318 feature: "libsql",
319 })
320 }
321 } else {
322 #[cfg(feature = "turso")]
323 {
324 run_turso_migrations(&set, url, auth_token.as_deref())
325 }
326 #[cfg(not(feature = "turso"))]
327 {
328 let _ = auth_token;
329 Err(CliError::MissingDriver {
330 dialect: "Turso (remote)",
331 feature: "turso",
332 })
333 }
334 }
335 }
336
337 #[cfg(all(not(feature = "turso"), not(feature = "libsql")))]
338 Credentials::Turso { .. } => Err(CliError::MissingDriver {
339 dialect: "Turso",
340 feature: "turso or libsql",
341 }),
342
343 Credentials::Postgres(creds) => {
345 let _ = creds;
346 core::cfg_select! {
347 feature = "postgres-sync" => run_postgres_sync_migrations(&set, creds),
348 feature = "tokio-postgres" => run_postgres_async_migrations(&set, creds),
349 _ => Err(CliError::MissingDriver {
350 dialect: "PostgreSQL",
351 feature: "postgres-sync or tokio-postgres",
352 }),
353 }
354 }
355
356 #[cfg(feature = "d1-http")]
357 Credentials::D1 {
358 account_id,
359 database_id,
360 token,
361 } => d1_http::run_migrations(&set, account_id, database_id, token),
362
363 #[cfg(not(feature = "d1-http"))]
364 Credentials::D1 { .. } => Err(CliError::MissingDriver {
365 dialect: "Cloudflare D1 (HTTP)",
366 feature: "d1-http",
367 }),
368
369 Credentials::AwsDataApi { .. } => Err(CliError::UnsupportedForDriver {
370 operation: "Running migrations against AWS Data API",
371 driver: "aws-data-api",
372 hint: "AWS RDS Data API migrations are not yet wired into this CLI. Run the \
373 generated SQL via `aws rds-data execute-statement` or connect directly \
374 with tokio-postgres.",
375 }),
376 }
377}
378
379#[cfg(any(
380 feature = "rusqlite",
381 feature = "libsql",
382 feature = "turso",
383 feature = "postgres-sync",
384 feature = "tokio-postgres",
385 feature = "d1-http",
386))]
387fn load_migration_set(
388 dialect: Dialect,
389 migrations_dir: &Path,
390 migrations_table: &str,
391 migrations_schema: &str,
392) -> Result<Migrations, CliError> {
393 let tracking = migration_tracking(dialect, migrations_table, migrations_schema);
394
395 let migrations = drizzle_migrations::MigrationDir::new(migrations_dir)
397 .discover()
398 .map_err(|e| CliError::Other(format!("Failed to load migrations: {e}")))?;
399 Ok(Migrations::with_tracking(
400 migrations,
401 dialect.to_base(),
402 tracking,
403 ))
404}
405
406#[cfg(any(
407 feature = "rusqlite",
408 feature = "libsql",
409 feature = "turso",
410 feature = "postgres-sync",
411 feature = "tokio-postgres",
412 feature = "d1-http",
413))]
414fn migration_tracking(
415 dialect: Dialect,
416 migrations_table: &str,
417 migrations_schema: &str,
418) -> drizzle_types::MigrationTracking {
419 let mut tracking = match dialect {
420 Dialect::Postgresql => drizzle_types::MigrationTracking::POSTGRES,
421 _ => drizzle_types::MigrationTracking::SQLITE,
422 };
423
424 if !migrations_table.trim().is_empty() {
425 tracking = tracking.table(migrations_table.to_owned());
426 }
427
428 if dialect == Dialect::Postgresql && !migrations_schema.trim().is_empty() {
429 tracking = tracking.schema(migrations_schema.to_owned());
430 }
431
432 tracking
433}
434
435#[cfg(any(
436 test,
437 feature = "rusqlite",
438 feature = "libsql",
439 feature = "turso",
440 feature = "postgres-sync",
441 feature = "tokio-postgres",
442 feature = "d1-http",
443))]
444pub(crate) fn build_migration_plan(
445 set: &Migrations,
446 applied: &[AppliedMigrationRecord],
447) -> Result<MigrationPlan, CliError> {
448 verify_applied_migrations_consistency(set, applied)?;
449
450 let applied_names = applied.iter().map(|m| m.name.clone()).collect::<Vec<_>>();
451 let pending = set.pending(&applied_names).collect::<Vec<_>>();
452
453 let pending_statements = pending
454 .iter()
455 .map(|m| {
456 m.statements()
457 .iter()
458 .filter(|stmt| !stmt.trim().is_empty())
459 .count()
460 })
461 .sum();
462
463 Ok(MigrationPlan {
464 applied_count: applied.len(),
465 pending_count: pending.len(),
466 pending_migrations: pending.iter().map(|m| m.tag().to_string()).collect(),
467 pending_statements,
468 })
469}
470
471#[cfg(any(
472 test,
473 feature = "rusqlite",
474 feature = "libsql",
475 feature = "turso",
476 feature = "postgres-sync",
477 feature = "tokio-postgres",
478 feature = "d1-http",
479))]
480fn verify_applied_migrations_consistency(
481 set: &Migrations,
482 applied: &[AppliedMigrationRecord],
483) -> Result<(), CliError> {
484 use std::collections::{HashMap, HashSet};
485
486 let mut local_by_name = HashMap::<&str, &str>::new();
487 for migration in set.all() {
488 if local_by_name
489 .insert(migration.name(), migration.hash())
490 .is_some()
491 {
492 return Err(CliError::MigrationError(format!(
493 "Local migrations contain duplicate name: {}",
494 migration.name()
495 )));
496 }
497 }
498
499 let mut seen_db_names = HashSet::<&str>::new();
500 for applied_row in applied {
501 if !seen_db_names.insert(applied_row.name.as_str()) {
502 return Err(CliError::MigrationError(format!(
503 "Database migration metadata contains duplicate name: {}",
504 applied_row.name
505 )));
506 }
507
508 let Some(local_hash) = local_by_name.get(applied_row.name.as_str()) else {
509 return Err(CliError::MigrationError(format!(
510 "Database contains applied migration not found locally (name: {})",
511 applied_row.name
512 )));
513 };
514
515 if *local_hash != applied_row.hash {
516 return Err(CliError::MigrationError(format!(
517 "Migration hash mismatch for {}: database={}, local={}",
518 applied_row.name, applied_row.hash, local_hash
519 )));
520 }
521 }
522
523 Ok(())
524}
525
526#[cfg(any(
527 feature = "rusqlite",
528 feature = "libsql",
529 feature = "turso",
530 feature = "postgres-sync",
531 feature = "tokio-postgres",
532))]
533fn escape_sql_literal(value: &str) -> String {
534 value.replace('\'', "''")
535}
536
537#[cfg(feature = "rusqlite")]
538fn ensure_sqlite_tracking_table(
539 conn: &rusqlite::Connection,
540 set: &Migrations,
541) -> Result<(), CliError> {
542 conn.execute(&set.create_table_sql(), [])
543 .map_err(|e| CliError::MigrationError(format!("Failed to create migrations table: {e}")))?;
544
545 let pragma_sql = format!(
546 "SELECT name FROM pragma_table_info('{}')",
547 escape_sql_literal(set.table_name())
548 );
549 let mut stmt = conn
550 .prepare(&pragma_sql)
551 .map_err(|e| CliError::MigrationError(e.to_string()))?;
552 let columns = stmt
553 .query_map([], |row| row.get::<_, String>(0))
554 .map_err(|e| CliError::MigrationError(e.to_string()))?
555 .collect::<Result<Vec<_>, _>>()
556 .map_err(|e| CliError::MigrationError(e.to_string()))?;
557 if columns.iter().any(|column| column == "name") {
558 return Ok(());
559 }
560
561 let mut stmt = conn
562 .prepare(&format!(
563 "SELECT id, hash, created_at FROM {} ORDER BY id ASC",
564 set.table_ident_sql()
565 ))
566 .map_err(|e| CliError::MigrationError(e.to_string()))?;
567 let applied = stmt
568 .query_map([], |row| {
569 Ok(drizzle_migrations::AppliedMigrationMetadata {
570 id: row.get::<_, Option<i64>>(0)?,
571 hash: row.get::<_, String>(1)?,
572 created_at: row.get::<_, i64>(2)?,
573 })
574 })
575 .map_err(|e| CliError::MigrationError(e.to_string()))?
576 .collect::<Result<Vec<_>, _>>()
577 .map_err(|e| CliError::MigrationError(e.to_string()))?;
578
579 let matched = drizzle_migrations::match_applied_migration_metadata(set.all(), &applied)
580 .map_err(|e| CliError::MigrationError(e.to_string()))?;
581
582 conn.execute("BEGIN", [])
583 .map_err(|e| CliError::MigrationError(e.to_string()))?;
584 let result = (|| -> Result<(), CliError> {
585 conn.execute(
586 &format!(
587 "ALTER TABLE {} ADD COLUMN \"name\" text",
588 set.table_ident_sql()
589 ),
590 [],
591 )
592 .map_err(|e| CliError::MigrationError(e.to_string()))?;
593 conn.execute(
594 &format!(
595 "ALTER TABLE {} ADD COLUMN \"applied_at\" TEXT",
596 set.table_ident_sql()
597 ),
598 [],
599 )
600 .map_err(|e| CliError::MigrationError(e.to_string()))?;
601
602 for row in matched {
603 let where_clause = if let Some(id) = row.id {
604 format!("\"id\" = {id}")
605 } else {
606 format!(
607 "\"created_at\" = {} AND \"hash\" = '{}'",
608 row.created_at,
609 escape_sql_literal(&row.hash)
610 )
611 };
612 conn.execute(
613 &format!(
614 "UPDATE {} SET \"name\" = '{}', \"applied_at\" = NULL WHERE {}",
615 set.table_ident_sql(),
616 escape_sql_literal(&row.name),
617 where_clause
618 ),
619 [],
620 )
621 .map_err(|e| CliError::MigrationError(e.to_string()))?;
622 }
623
624 Ok(())
625 })();
626
627 match result {
628 Ok(()) => {
629 conn.execute("COMMIT", [])
630 .map_err(|e| CliError::MigrationError(e.to_string()))?;
631 Ok(())
632 }
633 Err(err) => {
634 let _ = conn.execute("ROLLBACK", []);
635 Err(err)
636 }
637 }
638}
639
640#[cfg(feature = "libsql")]
641async fn ensure_sqlite_tracking_table_libsql(
642 conn: &libsql::Connection,
643 set: &Migrations,
644) -> Result<(), CliError> {
645 conn.execute(&set.create_table_sql(), ())
646 .await
647 .map_err(|e| CliError::MigrationError(format!("Failed to create migrations table: {e}")))?;
648
649 let pragma_sql = format!(
650 "SELECT name FROM pragma_table_info('{}')",
651 escape_sql_literal(set.table_name())
652 );
653 let mut rows = conn
654 .query(&pragma_sql, ())
655 .await
656 .map_err(|e| CliError::MigrationError(e.to_string()))?;
657 let mut has_name = false;
658 while let Ok(Some(row)) = rows.next().await {
659 if let Ok(name) = row.get::<String>(0)
660 && name == "name"
661 {
662 has_name = true;
663 break;
664 }
665 }
666 if has_name {
667 return Ok(());
668 }
669
670 let mut rows = conn
671 .query(
672 &format!(
673 "SELECT id, hash, created_at FROM {} ORDER BY id ASC",
674 set.table_ident_sql()
675 ),
676 (),
677 )
678 .await
679 .map_err(|e| CliError::MigrationError(e.to_string()))?;
680 let mut applied = Vec::new();
681 while let Ok(Some(row)) = rows.next().await {
682 let hash = row
683 .get::<String>(1)
684 .map_err(|e| CliError::MigrationError(e.to_string()))?;
685 let created_at = row
686 .get::<i64>(2)
687 .map_err(|e| CliError::MigrationError(e.to_string()))?;
688 applied.push(drizzle_migrations::AppliedMigrationMetadata {
689 id: row.get::<Option<i64>>(0).ok().flatten(),
690 hash,
691 created_at,
692 });
693 }
694
695 let matched = drizzle_migrations::match_applied_migration_metadata(set.all(), &applied)
696 .map_err(|e| CliError::MigrationError(e.to_string()))?;
697
698 let tx = conn
699 .transaction()
700 .await
701 .map_err(|e| CliError::MigrationError(e.to_string()))?;
702 tx.execute(
703 &format!(
704 "ALTER TABLE {} ADD COLUMN \"name\" text",
705 set.table_ident_sql()
706 ),
707 (),
708 )
709 .await
710 .map_err(|e| CliError::MigrationError(e.to_string()))?;
711 tx.execute(
712 &format!(
713 "ALTER TABLE {} ADD COLUMN \"applied_at\" TEXT",
714 set.table_ident_sql()
715 ),
716 (),
717 )
718 .await
719 .map_err(|e| CliError::MigrationError(e.to_string()))?;
720
721 for row in matched {
722 let where_clause = if let Some(id) = row.id {
723 format!("\"id\" = {id}")
724 } else {
725 format!(
726 "\"created_at\" = {} AND \"hash\" = '{}'",
727 row.created_at,
728 escape_sql_literal(&row.hash)
729 )
730 };
731 tx.execute(
732 &format!(
733 "UPDATE {} SET \"name\" = '{}', \"applied_at\" = NULL WHERE {}",
734 set.table_ident_sql(),
735 escape_sql_literal(&row.name),
736 where_clause
737 ),
738 (),
739 )
740 .await
741 .map_err(|e| CliError::MigrationError(e.to_string()))?;
742 }
743
744 tx.commit()
745 .await
746 .map_err(|e| CliError::MigrationError(e.to_string()))?;
747 Ok(())
748}
749
750#[cfg(feature = "postgres-sync")]
751fn ensure_postgres_tracking_table_sync(
752 client: &mut postgres::Client,
753 set: &Migrations,
754) -> Result<(), CliError> {
755 client
756 .execute(&set.create_table_sql(), &[])
757 .map_err(|e| CliError::MigrationError(format!("Failed to create migrations table: {e}")))?;
758
759 let schema = set.schema_name().unwrap_or("public");
760 let rows = client
761 .query(
762 "SELECT column_name FROM information_schema.columns WHERE table_schema = $1 AND table_name = $2",
763 &[&schema, &set.table_name()],
764 )
765 .map_err(|e| CliError::MigrationError(e.to_string()))?;
766 if rows
767 .iter()
768 .filter_map(|row| row.try_get::<_, String>(0).ok())
769 .any(|column| column == "name")
770 {
771 return Ok(());
772 }
773
774 let rows = client
775 .query(
776 &format!(
777 "SELECT id, hash, created_at FROM {} ORDER BY id ASC",
778 set.table_ident_sql()
779 ),
780 &[],
781 )
782 .map_err(|e| CliError::MigrationError(e.to_string()))?;
783 let applied = rows
784 .iter()
785 .map(|row| {
786 Ok(drizzle_migrations::AppliedMigrationMetadata {
787 id: row.try_get::<_, Option<i64>>(0).ok().flatten(),
788 hash: row.try_get::<_, String>(1)?,
789 created_at: row.try_get::<_, i64>(2)?,
790 })
791 })
792 .collect::<Result<Vec<_>, postgres::Error>>()
793 .map_err(|e| CliError::MigrationError(e.to_string()))?;
794
795 let matched = drizzle_migrations::match_applied_migration_metadata(set.all(), &applied)
796 .map_err(|e| CliError::MigrationError(e.to_string()))?;
797
798 client
799 .execute(
800 &format!(
801 "ALTER TABLE {} ADD COLUMN \"name\" TEXT",
802 set.table_ident_sql()
803 ),
804 &[],
805 )
806 .map_err(|e| CliError::MigrationError(e.to_string()))?;
807 client
808 .execute(
809 &format!(
810 "ALTER TABLE {} ADD COLUMN \"applied_at\" TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP",
811 set.table_ident_sql()
812 ),
813 &[],
814 )
815 .map_err(|e| CliError::MigrationError(e.to_string()))?;
816
817 for row in matched {
818 let where_clause = if let Some(id) = row.id {
819 format!("\"id\" = {id}")
820 } else {
821 format!(
822 "\"created_at\" = {} AND \"hash\" = '{}'",
823 row.created_at,
824 escape_sql_literal(&row.hash)
825 )
826 };
827 client
828 .execute(
829 &format!(
830 "UPDATE {} SET \"name\" = '{}', \"applied_at\" = NULL WHERE {}",
831 set.table_ident_sql(),
832 escape_sql_literal(&row.name),
833 where_clause
834 ),
835 &[],
836 )
837 .map_err(|e| CliError::MigrationError(e.to_string()))?;
838 }
839
840 Ok(())
841}
842
843#[cfg(feature = "tokio-postgres")]
844async fn ensure_postgres_tracking_table_async(
845 client: &tokio_postgres::Client,
846 set: &Migrations,
847) -> Result<(), CliError> {
848 client
849 .execute(&set.create_table_sql(), &[])
850 .await
851 .map_err(|e| CliError::MigrationError(format!("Failed to create migrations table: {e}")))?;
852
853 let schema = set.schema_name().unwrap_or("public");
854 let rows = client
855 .query(
856 "SELECT column_name FROM information_schema.columns WHERE table_schema = $1 AND table_name = $2",
857 &[&schema, &set.table_name()],
858 )
859 .await
860 .map_err(|e| CliError::MigrationError(e.to_string()))?;
861 if rows
862 .iter()
863 .filter_map(|row| row.try_get::<_, String>(0).ok())
864 .any(|column| column == "name")
865 {
866 return Ok(());
867 }
868
869 let rows = client
870 .query(
871 &format!(
872 "SELECT id, hash, created_at FROM {} ORDER BY id ASC",
873 set.table_ident_sql()
874 ),
875 &[],
876 )
877 .await
878 .map_err(|e| CliError::MigrationError(e.to_string()))?;
879 let applied = rows
880 .iter()
881 .map(|row| {
882 Ok(drizzle_migrations::AppliedMigrationMetadata {
883 id: row.try_get::<_, Option<i64>>(0).ok().flatten(),
884 hash: row.try_get::<_, String>(1)?,
885 created_at: row.try_get::<_, i64>(2)?,
886 })
887 })
888 .collect::<Result<Vec<_>, tokio_postgres::Error>>()
889 .map_err(|e| CliError::MigrationError(e.to_string()))?;
890
891 let matched = drizzle_migrations::match_applied_migration_metadata(set.all(), &applied)
892 .map_err(|e| CliError::MigrationError(e.to_string()))?;
893
894 client
895 .execute(
896 &format!(
897 "ALTER TABLE {} ADD COLUMN \"name\" TEXT",
898 set.table_ident_sql()
899 ),
900 &[],
901 )
902 .await
903 .map_err(|e| CliError::MigrationError(e.to_string()))?;
904 client
905 .execute(
906 &format!(
907 "ALTER TABLE {} ADD COLUMN \"applied_at\" TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP",
908 set.table_ident_sql()
909 ),
910 &[],
911 )
912 .await
913 .map_err(|e| CliError::MigrationError(e.to_string()))?;
914
915 for row in matched {
916 let where_clause = if let Some(id) = row.id {
917 format!("\"id\" = {id}")
918 } else {
919 format!(
920 "\"created_at\" = {} AND \"hash\" = '{}'",
921 row.created_at,
922 escape_sql_literal(&row.hash)
923 )
924 };
925 client
926 .execute(
927 &format!(
928 "UPDATE {} SET \"name\" = '{}', \"applied_at\" = NULL WHERE {}",
929 set.table_ident_sql(),
930 escape_sql_literal(&row.name),
931 where_clause
932 ),
933 &[],
934 )
935 .await
936 .map_err(|e| CliError::MigrationError(e.to_string()))?;
937 }
938
939 Ok(())
940}
941
942fn is_destructive_statement(sql: &str) -> bool {
943 let s = sql.trim().to_uppercase();
944 s.contains("DROP TABLE")
945 || s.contains("DROP COLUMN")
946 || s.contains("DROP INDEX")
947 || s.contains("DROP VIEW")
948 || s.contains("DROP MATERIALIZED VIEW")
949 || s.contains("DROP TYPE")
950 || s.contains("DROP SCHEMA")
951 || s.contains("DROP SEQUENCE")
952 || s.contains("DROP ROLE")
953 || s.contains("DROP POLICY")
954 || s.contains("TRUNCATE")
955 || (s.contains("ALTER TABLE") && s.contains(" DROP "))
956}
957
958#[cfg(any(feature = "postgres-sync", feature = "tokio-postgres"))]
959fn is_postgres_concurrent_index_statement(sql: &str) -> bool {
960 let s = sql.trim().to_ascii_uppercase();
961 (s.starts_with("CREATE") || s.starts_with("DROP")) && s.contains("INDEX CONCURRENTLY")
962}
963
964#[cfg(any(feature = "postgres-sync", feature = "tokio-postgres"))]
965fn has_postgres_concurrent_index(statements: &[String]) -> bool {
966 statements
967 .iter()
968 .any(|stmt| is_postgres_concurrent_index_statement(stmt))
969}
970
971fn confirm_destructive() -> Result<bool, CliError> {
972 use std::io::{self, IsTerminal, Write};
973
974 if !io::stdin().is_terminal() {
975 return Err(CliError::Other(
976 "Refusing to apply potentially destructive changes in non-interactive mode. Use --explain or --force."
977 .into(),
978 ));
979 }
980
981 println!(
982 "{}",
983 output::warning("Potentially destructive changes detected (DROP/TRUNCATE/etc).")
984 );
985 print!("Apply anyway? [y/N]: ");
986 io::stdout()
987 .flush()
988 .map_err(|e| CliError::IoError(e.to_string()))?;
989
990 let mut line = String::new();
991 io::stdin()
992 .read_line(&mut line)
993 .map_err(|e| CliError::IoError(e.to_string()))?;
994 let ans = line.trim().to_ascii_lowercase();
995 Ok(ans == "y" || ans == "yes")
996}
997
998fn generate_push_sql(
999 current: &Snapshot,
1000 desired: &Snapshot,
1001 breakpoints: bool,
1002) -> Result<(Vec<String>, Vec<String>), CliError> {
1003 match (current, desired) {
1004 (Snapshot::Sqlite(prev_snap), Snapshot::Sqlite(curr_snap)) => {
1005 use drizzle_migrations::sqlite::collection::SQLiteDDL;
1006 use drizzle_migrations::sqlite::diff::compute_migration;
1007
1008 let prev_ddl = SQLiteDDL::from_entities(prev_snap.ddl.clone());
1009 let cur_ddl = SQLiteDDL::from_entities(curr_snap.ddl.clone());
1010
1011 let diff = compute_migration(&prev_ddl, &cur_ddl);
1012 Ok((diff.sql_statements, diff.warnings))
1013 }
1014 (Snapshot::Postgres(prev_snap), Snapshot::Postgres(curr_snap)) => {
1015 use drizzle_migrations::postgres::diff_full_snapshots;
1016 use drizzle_migrations::postgres::statements::PostgresGenerator;
1017
1018 let diff = diff_full_snapshots(prev_snap, curr_snap);
1019 let generator = PostgresGenerator::new().with_breakpoints(breakpoints);
1020 Ok((generator.generate(&diff.diffs), Vec::new()))
1021 }
1022 _ => Err(CliError::DialectMismatch),
1023 }
1024}
1025
1026fn execute_statements(
1027 credentials: &Credentials,
1028 _dialect: Dialect,
1029 statements: &[String],
1030) -> Result<(), CliError> {
1031 let _ = statements;
1034
1035 match credentials {
1036 #[cfg(feature = "rusqlite")]
1037 Credentials::Sqlite { path } => execute_sqlite_statements(path, statements),
1038
1039 #[cfg(not(feature = "rusqlite"))]
1040 Credentials::Sqlite { .. } => Err(CliError::MissingDriver {
1041 dialect: "SQLite",
1042 feature: "rusqlite",
1043 }),
1044
1045 #[cfg(any(feature = "libsql", feature = "turso"))]
1046 Credentials::Turso { url, auth_token } => {
1047 if is_local_libsql(url) {
1048 #[cfg(feature = "libsql")]
1049 {
1050 execute_libsql_local_statements(url, statements)
1051 }
1052 #[cfg(not(feature = "libsql"))]
1053 {
1054 Err(CliError::MissingDriver {
1055 dialect: "LibSQL (local)",
1056 feature: "libsql",
1057 })
1058 }
1059 } else {
1060 #[cfg(feature = "turso")]
1061 {
1062 execute_turso_statements(url, auth_token.as_deref(), statements)
1063 }
1064 #[cfg(not(feature = "turso"))]
1065 {
1066 let _ = auth_token;
1067 Err(CliError::MissingDriver {
1068 dialect: "Turso (remote)",
1069 feature: "turso",
1070 })
1071 }
1072 }
1073 }
1074
1075 #[cfg(all(not(feature = "turso"), not(feature = "libsql")))]
1076 Credentials::Turso { .. } => Err(CliError::MissingDriver {
1077 dialect: "Turso",
1078 feature: "turso or libsql",
1079 }),
1080
1081 Credentials::Postgres(creds) => {
1082 let _ = (creds, &statements);
1083 core::cfg_select! {
1084 feature = "postgres-sync" => execute_postgres_sync_statements(creds, statements),
1085 feature = "tokio-postgres" => execute_postgres_async_statements(creds, statements),
1086 _ => Err(CliError::MissingDriver {
1087 dialect: "PostgreSQL",
1088 feature: "postgres-sync or tokio-postgres",
1089 }),
1090 }
1091 }
1092
1093 #[cfg(feature = "d1-http")]
1094 Credentials::D1 {
1095 account_id,
1096 database_id,
1097 token,
1098 } => d1_http::execute_statements(account_id, database_id, token, statements),
1099
1100 #[cfg(not(feature = "d1-http"))]
1101 Credentials::D1 { .. } => Err(CliError::MissingDriver {
1102 dialect: "Cloudflare D1 (HTTP)",
1103 feature: "d1-http",
1104 }),
1105
1106 Credentials::AwsDataApi { .. } => Err(CliError::UnsupportedForDriver {
1107 operation: "Direct SQL execution against AWS Data API",
1108 driver: "aws-data-api",
1109 hint: "Use `aws rds-data execute-statement --sql=\"...\"` with the matching \
1110 --resource-arn / --secret-arn / --database, or connect directly via \
1111 tokio-postgres.",
1112 }),
1113 }
1114}
1115
1116#[allow(dead_code)]
1118fn is_local_libsql(url: &str) -> bool {
1119 url.starts_with("file:")
1120 || url.starts_with("./")
1121 || url.starts_with('/')
1122 || !url.contains("://")
1123}
1124
1125#[cfg(any(feature = "rusqlite", feature = "libsql", feature = "turso"))]
1126fn process_sqlite_uniques_from_indexes(
1127 raw_indexes: &[drizzle_migrations::sqlite::introspect::RawIndexInfo],
1128 index_columns: &[drizzle_migrations::sqlite::introspect::RawIndexColumn],
1129) -> Vec<drizzle_migrations::sqlite::UniqueConstraint> {
1130 use drizzle_migrations::sqlite::UniqueConstraint;
1131
1132 let mut uniques = Vec::new();
1133
1134 for idx in raw_indexes.iter().filter(|i| i.origin == "u") {
1135 let mut cols: Vec<(i32, String)> = index_columns
1136 .iter()
1137 .filter(|c| c.index_name == idx.name && c.key)
1138 .filter_map(|c| c.name.clone().map(|n| (c.seqno, n)))
1139 .collect();
1140 cols.sort_by_key(|(seq, _)| *seq);
1141 let col_names: Vec<String> = cols.into_iter().map(|(_, n)| n).collect();
1142 if col_names.is_empty() {
1143 continue;
1144 }
1145
1146 let name_explicit = !idx.name.starts_with("sqlite_autoindex_");
1147 let constraint_name = if name_explicit {
1148 idx.name.clone()
1149 } else {
1150 let refs: Vec<&str> = col_names.iter().map(String::as_str).collect();
1151 drizzle_migrations::sqlite::ddl::name_for_unique(&idx.table, &refs)
1152 };
1153
1154 let mut uniq =
1155 UniqueConstraint::from_strings(idx.table.clone(), constraint_name, col_names);
1156 uniq.name_explicit = name_explicit;
1157 uniques.push(uniq);
1158 }
1159
1160 uniques
1161}
1162
1163#[cfg(feature = "rusqlite")]
1168fn execute_sqlite_statements(path: &str, statements: &[String]) -> Result<(), CliError> {
1169 let conn = rusqlite::Connection::open(path).map_err(|e| {
1170 CliError::ConnectionError(format!("Failed to open SQLite database '{path}': {e}"))
1171 })?;
1172
1173 conn.execute("BEGIN", [])
1174 .map_err(|e| CliError::MigrationError(e.to_string()))?;
1175
1176 for stmt in statements {
1177 let s = stmt.trim();
1178 if s.is_empty() {
1179 continue;
1180 }
1181 if let Err(e) = conn.execute(s, []) {
1182 let _ = conn.execute("ROLLBACK", []);
1183 return Err(CliError::MigrationError(format!(
1184 "Statement failed: {e}\n{s}"
1185 )));
1186 }
1187 }
1188
1189 conn.execute("COMMIT", [])
1190 .map_err(|e| CliError::MigrationError(e.to_string()))?;
1191
1192 Ok(())
1193}
1194
1195#[cfg(feature = "rusqlite")]
1196fn run_sqlite_migrations(set: &Migrations, path: &str) -> Result<MigrationResult, CliError> {
1197 let conn = rusqlite::Connection::open(path).map_err(|e| {
1198 CliError::ConnectionError(format!("Failed to open SQLite database '{path}': {e}"))
1199 })?;
1200
1201 ensure_sqlite_tracking_table(&conn, set)?;
1202
1203 let applied_names = query_applied_names_sqlite(&conn, set)?;
1205
1206 let pending: Vec<_> = set.pending(&applied_names).collect();
1208 if pending.is_empty() {
1209 return Ok(MigrationResult {
1210 applied_count: 0,
1211 applied_migrations: vec![],
1212 });
1213 }
1214
1215 conn.execute("BEGIN", [])
1217 .map_err(|e| CliError::MigrationError(e.to_string()))?;
1218
1219 let mut applied = Vec::new();
1220 for migration in &pending {
1221 for stmt in migration.statements() {
1222 if !stmt.trim().is_empty()
1223 && let Err(e) = conn.execute(stmt, [])
1224 {
1225 let _ = conn.execute("ROLLBACK", []);
1226 return Err(CliError::MigrationError(format!(
1227 "Migration '{}' failed: {}",
1228 migration.hash(),
1229 e
1230 )));
1231 }
1232 }
1233 if let Err(e) = conn.execute(&set.record_migration_sql(migration), []) {
1234 let _ = conn.execute("ROLLBACK", []);
1235 return Err(CliError::MigrationError(e.to_string()));
1236 }
1237 applied.push(migration.hash().to_string());
1238 }
1239
1240 conn.execute("COMMIT", [])
1241 .map_err(|e| CliError::MigrationError(e.to_string()))?;
1242
1243 Ok(MigrationResult {
1244 applied_count: applied.len(),
1245 applied_migrations: applied,
1246 })
1247}
1248
1249#[cfg(feature = "rusqlite")]
1250fn inspect_sqlite_migrations(set: &Migrations, path: &str) -> Result<MigrationPlan, CliError> {
1251 let conn = rusqlite::Connection::open(path).map_err(|e| {
1252 CliError::ConnectionError(format!("Failed to open SQLite database '{path}': {e}"))
1253 })?;
1254
1255 ensure_sqlite_tracking_table(&conn, set)?;
1256 let applied = query_applied_records_sqlite(&conn, set)?;
1257 build_migration_plan(set, &applied)
1258}
1259
1260#[cfg(feature = "rusqlite")]
1261fn query_applied_records_sqlite(
1262 conn: &rusqlite::Connection,
1263 set: &Migrations,
1264) -> Result<Vec<AppliedMigrationRecord>, CliError> {
1265 let sql = format!(
1266 r#"SELECT hash, "name" FROM {} WHERE "name" IS NOT NULL ORDER BY id;"#,
1267 set.table_ident_sql()
1268 );
1269 let Ok(mut stmt) = conn.prepare(&sql) else {
1270 return Ok(vec![]); };
1272
1273 let mut applied = Vec::new();
1274 let rows = stmt
1275 .query_map([], |row| {
1276 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
1277 })
1278 .map_err(|e| CliError::MigrationError(e.to_string()))?;
1279
1280 for row in rows {
1281 let (hash, name) = row.map_err(|e| CliError::MigrationError(e.to_string()))?;
1282 applied.push(AppliedMigrationRecord { hash, name });
1283 }
1284
1285 Ok(applied)
1286}
1287
1288#[cfg(feature = "rusqlite")]
1289fn query_applied_names_sqlite(
1290 conn: &rusqlite::Connection,
1291 set: &Migrations,
1292) -> Result<Vec<String>, CliError> {
1293 let Ok(mut stmt) = conn.prepare(&set.applied_names_sql()) else {
1294 return Ok(vec![]); };
1296
1297 let names = stmt
1298 .query_map([], |row| row.get::<_, String>(0))
1299 .map_err(|e| CliError::MigrationError(e.to_string()))?
1300 .filter_map(Result::ok)
1301 .collect();
1302
1303 Ok(names)
1304}
1305
1306#[cfg(feature = "postgres-sync")]
1311fn execute_postgres_sync_statements(
1312 creds: &PostgresCreds,
1313 statements: &[String],
1314) -> Result<(), CliError> {
1315 let url = creds.connection_url();
1316 let mut client = postgres::Client::connect(&url, postgres::NoTls)
1317 .map_err(|e| CliError::ConnectionError(format!("Failed to connect to PostgreSQL: {e}")))?;
1318
1319 if has_postgres_concurrent_index(statements) {
1320 for stmt in statements {
1322 let s = stmt.trim();
1323 if s.is_empty() {
1324 continue;
1325 }
1326 client
1327 .execute(s, &[])
1328 .map_err(|e| CliError::MigrationError(format!("Statement failed: {e}\n{s}")))?;
1329 }
1330 } else {
1331 let mut tx = client
1332 .transaction()
1333 .map_err(|e| CliError::MigrationError(e.to_string()))?;
1334
1335 for stmt in statements {
1336 let s = stmt.trim();
1337 if s.is_empty() {
1338 continue;
1339 }
1340 tx.execute(s, &[])
1341 .map_err(|e| CliError::MigrationError(format!("Statement failed: {e}\n{s}")))?;
1342 }
1343
1344 tx.commit()
1345 .map_err(|e| CliError::MigrationError(e.to_string()))?;
1346 }
1347
1348 Ok(())
1349}
1350
1351#[cfg(feature = "postgres-sync")]
1352fn run_postgres_sync_migrations(
1353 set: &Migrations,
1354 creds: &PostgresCreds,
1355) -> Result<MigrationResult, CliError> {
1356 let url = creds.connection_url();
1357 let mut client = postgres::Client::connect(&url, postgres::NoTls)
1358 .map_err(|e| CliError::ConnectionError(format!("Failed to connect to PostgreSQL: {e}")))?;
1359
1360 if let Some(schema_sql) = set.create_schema_sql() {
1362 client
1363 .execute(&schema_sql, &[])
1364 .map_err(|e| CliError::MigrationError(e.to_string()))?;
1365 }
1366
1367 ensure_postgres_tracking_table_sync(&mut client, set)?;
1368
1369 let rows = client
1371 .query(&set.applied_names_sql(), &[])
1372 .unwrap_or_default();
1373 let applied_names: Vec<String> = rows.iter().filter_map(|r| r.try_get(0).ok()).collect();
1374
1375 let pending: Vec<_> = set.pending(&applied_names).collect();
1377 if pending.is_empty() {
1378 return Ok(MigrationResult {
1379 applied_count: 0,
1380 applied_migrations: vec![],
1381 });
1382 }
1383
1384 let has_concurrent = pending
1385 .iter()
1386 .any(|m| has_postgres_concurrent_index(m.statements()));
1387
1388 if has_concurrent {
1389 let mut applied = Vec::new();
1391 for migration in &pending {
1392 for stmt in migration.statements() {
1393 if !stmt.trim().is_empty() {
1394 client.execute(stmt, &[]).map_err(|e| {
1395 CliError::MigrationError(format!(
1396 "Migration '{}' failed: {}",
1397 migration.hash(),
1398 e
1399 ))
1400 })?;
1401 }
1402 }
1403 client
1404 .execute(&set.record_migration_sql(migration), &[])
1405 .map_err(|e| CliError::MigrationError(e.to_string()))?;
1406 applied.push(migration.hash().to_string());
1407 }
1408
1409 return Ok(MigrationResult {
1410 applied_count: applied.len(),
1411 applied_migrations: applied,
1412 });
1413 }
1414
1415 let mut tx = client
1417 .transaction()
1418 .map_err(|e| CliError::MigrationError(e.to_string()))?;
1419
1420 let mut applied = Vec::new();
1421 for migration in &pending {
1422 for stmt in migration.statements() {
1423 if !stmt.trim().is_empty() {
1424 tx.execute(stmt, &[]).map_err(|e| {
1425 CliError::MigrationError(format!(
1426 "Migration '{}' failed: {}",
1427 migration.hash(),
1428 e
1429 ))
1430 })?;
1431 }
1432 }
1433 tx.execute(&set.record_migration_sql(migration), &[])
1434 .map_err(|e| CliError::MigrationError(e.to_string()))?;
1435 applied.push(migration.hash().to_string());
1436 }
1437
1438 tx.commit()
1439 .map_err(|e| CliError::MigrationError(e.to_string()))?;
1440
1441 Ok(MigrationResult {
1442 applied_count: applied.len(),
1443 applied_migrations: applied,
1444 })
1445}
1446
1447#[cfg(feature = "postgres-sync")]
1448fn inspect_postgres_sync_migrations(
1449 set: &Migrations,
1450 creds: &PostgresCreds,
1451) -> Result<MigrationPlan, CliError> {
1452 let url = creds.connection_url();
1453 let mut client = postgres::Client::connect(&url, postgres::NoTls)
1454 .map_err(|e| CliError::ConnectionError(format!("Failed to connect to PostgreSQL: {e}")))?;
1455
1456 if let Some(schema_sql) = set.create_schema_sql() {
1457 client
1458 .execute(&schema_sql, &[])
1459 .map_err(|e| CliError::MigrationError(e.to_string()))?;
1460 }
1461
1462 ensure_postgres_tracking_table_sync(&mut client, set)?;
1463 let applied = query_applied_records_postgres_sync(&mut client, set);
1464 build_migration_plan(set, &applied)
1465}
1466
1467#[cfg(feature = "postgres-sync")]
1468fn query_applied_records_postgres_sync(
1469 client: &mut postgres::Client,
1470 set: &Migrations,
1471) -> Vec<AppliedMigrationRecord> {
1472 let sql = format!(
1473 r#"SELECT hash, "name" FROM {} WHERE "name" IS NOT NULL ORDER BY id;"#,
1474 set.table_ident_sql()
1475 );
1476 let rows = client.query(&sql, &[]).unwrap_or_default();
1477
1478 let mut applied = Vec::new();
1479 for row in rows {
1480 let hash = row.try_get::<_, Option<String>>(0).ok().flatten();
1481 let name = row.try_get::<_, Option<String>>(1).ok().flatten();
1482 if let (Some(hash), Some(name)) = (hash, name) {
1483 applied.push(AppliedMigrationRecord { hash, name });
1484 }
1485 }
1486
1487 applied
1488}
1489
1490#[cfg(all(feature = "tokio-postgres", not(feature = "postgres-sync")))]
1495fn execute_postgres_async_statements(
1496 creds: &PostgresCreds,
1497 statements: &[String],
1498) -> Result<(), CliError> {
1499 let rt = tokio::runtime::Builder::new_current_thread()
1500 .enable_all()
1501 .build()
1502 .map_err(|e| CliError::Other(format!("Failed to create async runtime: {}", e)))?;
1503
1504 rt.block_on(execute_postgres_async_inner(creds, statements))
1505}
1506
1507#[cfg(all(feature = "tokio-postgres", not(feature = "postgres-sync")))]
1508async fn execute_postgres_async_inner(
1509 creds: &PostgresCreds,
1510 statements: &[String],
1511) -> Result<(), CliError> {
1512 let url = creds.connection_url();
1513 let (mut client, connection) = tokio_postgres::connect(&url, tokio_postgres::NoTls)
1514 .await
1515 .map_err(|e| {
1516 CliError::ConnectionError(format!("Failed to connect to PostgreSQL: {}", e))
1517 })?;
1518
1519 tokio::spawn(async move {
1520 if let Err(e) = connection.await {
1521 eprintln!(
1522 "{}",
1523 output::err_line(&format!("PostgreSQL connection error: {e}"))
1524 );
1525 }
1526 });
1527
1528 if has_postgres_concurrent_index(statements) {
1529 for stmt in statements {
1531 let s = stmt.trim();
1532 if s.is_empty() {
1533 continue;
1534 }
1535 client
1536 .execute(s, &[])
1537 .await
1538 .map_err(|e| CliError::MigrationError(format!("Statement failed: {}\n{}", e, s)))?;
1539 }
1540 } else {
1541 let tx = client
1542 .transaction()
1543 .await
1544 .map_err(|e| CliError::MigrationError(e.to_string()))?;
1545
1546 for stmt in statements {
1547 let s = stmt.trim();
1548 if s.is_empty() {
1549 continue;
1550 }
1551 tx.execute(s, &[])
1552 .await
1553 .map_err(|e| CliError::MigrationError(format!("Statement failed: {}\n{}", e, s)))?;
1554 }
1555
1556 tx.commit()
1557 .await
1558 .map_err(|e| CliError::MigrationError(e.to_string()))?;
1559 }
1560
1561 Ok(())
1562}
1563
1564#[cfg(feature = "tokio-postgres")]
1565#[allow(dead_code)] fn run_postgres_async_migrations(
1567 set: &Migrations,
1568 creds: &PostgresCreds,
1569) -> Result<MigrationResult, CliError> {
1570 let rt = tokio::runtime::Builder::new_current_thread()
1571 .enable_all()
1572 .build()
1573 .map_err(|e| CliError::Other(format!("Failed to create async runtime: {e}")))?;
1574
1575 rt.block_on(run_postgres_async_inner(set, creds))
1576}
1577
1578#[cfg(feature = "tokio-postgres")]
1579#[allow(dead_code)] fn inspect_postgres_async_migrations(
1581 set: &Migrations,
1582 creds: &PostgresCreds,
1583) -> Result<MigrationPlan, CliError> {
1584 let rt = tokio::runtime::Builder::new_current_thread()
1585 .enable_all()
1586 .build()
1587 .map_err(|e| CliError::Other(format!("Failed to create async runtime: {e}")))?;
1588
1589 rt.block_on(inspect_postgres_async_inner(set, creds))
1590}
1591
1592#[cfg(feature = "tokio-postgres")]
1593#[allow(dead_code)]
1594async fn inspect_postgres_async_inner(
1595 set: &Migrations,
1596 creds: &PostgresCreds,
1597) -> Result<MigrationPlan, CliError> {
1598 let url = creds.connection_url();
1599 let (client, connection) = tokio_postgres::connect(&url, tokio_postgres::NoTls)
1600 .await
1601 .map_err(|e| CliError::ConnectionError(format!("Failed to connect to PostgreSQL: {e}")))?;
1602
1603 tokio::spawn(async move {
1604 if let Err(e) = connection.await {
1605 eprintln!(
1606 "{}",
1607 output::err_line(&format!("PostgreSQL connection error: {e}"))
1608 );
1609 }
1610 });
1611
1612 if let Some(schema_sql) = set.create_schema_sql() {
1613 client
1614 .execute(&schema_sql, &[])
1615 .await
1616 .map_err(|e| CliError::MigrationError(e.to_string()))?;
1617 }
1618
1619 ensure_postgres_tracking_table_async(&client, set).await?;
1620 let applied = query_applied_records_postgres_async(&client, set).await;
1621 build_migration_plan(set, &applied)
1622}
1623
1624#[cfg(feature = "tokio-postgres")]
1625async fn query_applied_records_postgres_async(
1626 client: &tokio_postgres::Client,
1627 set: &Migrations,
1628) -> Vec<AppliedMigrationRecord> {
1629 let sql = format!(
1630 r#"SELECT hash, "name" FROM {} WHERE "name" IS NOT NULL ORDER BY id;"#,
1631 set.table_ident_sql()
1632 );
1633 let rows = client.query(&sql, &[]).await.unwrap_or_default();
1634
1635 let mut applied = Vec::new();
1636 for row in rows {
1637 let hash = row.try_get::<_, Option<String>>(0).ok().flatten();
1638 let name = row.try_get::<_, Option<String>>(1).ok().flatten();
1639 if let (Some(hash), Some(name)) = (hash, name) {
1640 applied.push(AppliedMigrationRecord { hash, name });
1641 }
1642 }
1643
1644 applied
1645}
1646
1647#[cfg(feature = "tokio-postgres")]
1648#[allow(dead_code)]
1649async fn run_postgres_async_inner(
1650 set: &Migrations,
1651 creds: &PostgresCreds,
1652) -> Result<MigrationResult, CliError> {
1653 let url = creds.connection_url();
1654 let (mut client, connection) = tokio_postgres::connect(&url, tokio_postgres::NoTls)
1655 .await
1656 .map_err(|e| CliError::ConnectionError(format!("Failed to connect to PostgreSQL: {e}")))?;
1657
1658 tokio::spawn(async move {
1660 if let Err(e) = connection.await {
1661 eprintln!(
1662 "{}",
1663 output::err_line(&format!("PostgreSQL connection error: {e}"))
1664 );
1665 }
1666 });
1667
1668 if let Some(schema_sql) = set.create_schema_sql() {
1670 client
1671 .execute(&schema_sql, &[])
1672 .await
1673 .map_err(|e| CliError::MigrationError(e.to_string()))?;
1674 }
1675
1676 ensure_postgres_tracking_table_async(&client, set).await?;
1677
1678 let rows = client
1680 .query(&set.applied_names_sql(), &[])
1681 .await
1682 .unwrap_or_default();
1683 let applied_names: Vec<String> = rows.iter().filter_map(|r| r.try_get(0).ok()).collect();
1684
1685 let pending: Vec<_> = set.pending(&applied_names).collect();
1687 if pending.is_empty() {
1688 return Ok(MigrationResult {
1689 applied_count: 0,
1690 applied_migrations: vec![],
1691 });
1692 }
1693
1694 let has_concurrent = pending
1695 .iter()
1696 .any(|m| has_postgres_concurrent_index(m.statements()));
1697
1698 if has_concurrent {
1699 let mut applied = Vec::new();
1701 for migration in &pending {
1702 for stmt in migration.statements() {
1703 if !stmt.trim().is_empty() {
1704 client.execute(stmt, &[]).await.map_err(|e| {
1705 CliError::MigrationError(format!(
1706 "Migration '{}' failed: {}",
1707 migration.hash(),
1708 e
1709 ))
1710 })?;
1711 }
1712 }
1713 client
1714 .execute(&set.record_migration_sql(migration), &[])
1715 .await
1716 .map_err(|e| CliError::MigrationError(e.to_string()))?;
1717 applied.push(migration.hash().to_string());
1718 }
1719
1720 return Ok(MigrationResult {
1721 applied_count: applied.len(),
1722 applied_migrations: applied,
1723 });
1724 }
1725
1726 let tx = client
1728 .transaction()
1729 .await
1730 .map_err(|e| CliError::MigrationError(e.to_string()))?;
1731
1732 let mut applied = Vec::new();
1733 for migration in &pending {
1734 for stmt in migration.statements() {
1735 if !stmt.trim().is_empty() {
1736 tx.execute(stmt, &[]).await.map_err(|e| {
1737 CliError::MigrationError(format!(
1738 "Migration '{}' failed: {}",
1739 migration.hash(),
1740 e
1741 ))
1742 })?;
1743 }
1744 }
1745 tx.execute(&set.record_migration_sql(migration), &[])
1746 .await
1747 .map_err(|e| CliError::MigrationError(e.to_string()))?;
1748 applied.push(migration.hash().to_string());
1749 }
1750
1751 tx.commit()
1752 .await
1753 .map_err(|e| CliError::MigrationError(e.to_string()))?;
1754
1755 Ok(MigrationResult {
1756 applied_count: applied.len(),
1757 applied_migrations: applied,
1758 })
1759}
1760
1761#[cfg(feature = "libsql")]
1766fn execute_libsql_local_statements(path: &str, statements: &[String]) -> Result<(), CliError> {
1767 let rt = tokio::runtime::Builder::new_current_thread()
1768 .enable_all()
1769 .build()
1770 .map_err(|e| CliError::Other(format!("Failed to create async runtime: {e}")))?;
1771
1772 rt.block_on(execute_libsql_local_inner(path, statements))
1773}
1774
1775#[cfg(feature = "libsql")]
1776async fn execute_libsql_local_inner(path: &str, statements: &[String]) -> Result<(), CliError> {
1777 let db = libsql::Builder::new_local(path)
1778 .build()
1779 .await
1780 .map_err(|e| {
1781 CliError::ConnectionError(format!("Failed to open LibSQL database '{path}': {e}"))
1782 })?;
1783
1784 let conn = db
1785 .connect()
1786 .map_err(|e| CliError::ConnectionError(e.to_string()))?;
1787
1788 let tx = conn
1789 .transaction()
1790 .await
1791 .map_err(|e| CliError::MigrationError(e.to_string()))?;
1792
1793 for stmt in statements {
1794 let s = stmt.trim();
1795 if s.is_empty() {
1796 continue;
1797 }
1798 if let Err(e) = tx.execute(s, ()).await {
1799 tx.rollback().await.ok();
1800 return Err(CliError::MigrationError(format!(
1801 "Statement failed: {e}\n{s}"
1802 )));
1803 }
1804 }
1805
1806 tx.commit()
1807 .await
1808 .map_err(|e| CliError::MigrationError(e.to_string()))?;
1809
1810 Ok(())
1811}
1812
1813#[cfg(feature = "libsql")]
1814fn run_libsql_local_migrations(set: &Migrations, path: &str) -> Result<MigrationResult, CliError> {
1815 let rt = tokio::runtime::Builder::new_current_thread()
1816 .enable_all()
1817 .build()
1818 .map_err(|e| CliError::Other(format!("Failed to create async runtime: {e}")))?;
1819
1820 rt.block_on(run_libsql_local_inner(set, path))
1821}
1822
1823#[cfg(feature = "libsql")]
1824fn inspect_libsql_local_migrations(
1825 set: &Migrations,
1826 path: &str,
1827) -> Result<MigrationPlan, CliError> {
1828 let rt = tokio::runtime::Builder::new_current_thread()
1829 .enable_all()
1830 .build()
1831 .map_err(|e| CliError::Other(format!("Failed to create async runtime: {e}")))?;
1832
1833 rt.block_on(inspect_libsql_local_inner(set, path))
1834}
1835
1836#[cfg(feature = "libsql")]
1837async fn inspect_libsql_local_inner(
1838 set: &Migrations,
1839 path: &str,
1840) -> Result<MigrationPlan, CliError> {
1841 let db = libsql::Builder::new_local(path)
1842 .build()
1843 .await
1844 .map_err(|e| {
1845 CliError::ConnectionError(format!("Failed to open LibSQL database '{path}': {e}"))
1846 })?;
1847
1848 let conn = db
1849 .connect()
1850 .map_err(|e| CliError::ConnectionError(e.to_string()))?;
1851
1852 ensure_sqlite_tracking_table_libsql(&conn, set).await?;
1853 let applied = query_applied_records_libsql(&conn, set).await?;
1854 build_migration_plan(set, &applied)
1855}
1856
1857#[cfg(feature = "libsql")]
1858async fn run_libsql_local_inner(set: &Migrations, path: &str) -> Result<MigrationResult, CliError> {
1859 let db = libsql::Builder::new_local(path)
1860 .build()
1861 .await
1862 .map_err(|e| {
1863 CliError::ConnectionError(format!("Failed to open LibSQL database '{path}': {e}"))
1864 })?;
1865
1866 let conn = db
1867 .connect()
1868 .map_err(|e| CliError::ConnectionError(e.to_string()))?;
1869
1870 ensure_sqlite_tracking_table_libsql(&conn, set).await?;
1871
1872 let applied_names = query_applied_names_libsql(&conn, set).await?;
1874
1875 let pending: Vec<_> = set.pending(&applied_names).collect();
1877 if pending.is_empty() {
1878 return Ok(MigrationResult {
1879 applied_count: 0,
1880 applied_migrations: vec![],
1881 });
1882 }
1883
1884 let tx = conn
1886 .transaction()
1887 .await
1888 .map_err(|e| CliError::MigrationError(e.to_string()))?;
1889
1890 let mut applied = Vec::new();
1891 for migration in &pending {
1892 for stmt in migration.statements() {
1893 if !stmt.trim().is_empty()
1894 && let Err(e) = tx.execute(stmt, ()).await
1895 {
1896 tx.rollback().await.ok();
1897 return Err(CliError::MigrationError(format!(
1898 "Migration '{}' failed: {}",
1899 migration.hash(),
1900 e
1901 )));
1902 }
1903 }
1904 if let Err(e) = tx.execute(&set.record_migration_sql(migration), ()).await {
1905 tx.rollback().await.ok();
1906 return Err(CliError::MigrationError(e.to_string()));
1907 }
1908 applied.push(migration.hash().to_string());
1909 }
1910
1911 tx.commit()
1912 .await
1913 .map_err(|e| CliError::MigrationError(e.to_string()))?;
1914
1915 Ok(MigrationResult {
1916 applied_count: applied.len(),
1917 applied_migrations: applied,
1918 })
1919}
1920
1921#[cfg(feature = "libsql")]
1922async fn query_applied_names_libsql(
1923 conn: &libsql::Connection,
1924 set: &Migrations,
1925) -> Result<Vec<String>, CliError> {
1926 let Ok(mut rows) = conn.query(&set.applied_names_sql(), ()).await else {
1927 return Ok(vec![]); };
1929
1930 let mut names = Vec::new();
1931 while let Ok(Some(row)) = rows.next().await {
1932 if let Ok(name) = row.get::<String>(0) {
1933 names.push(name);
1934 }
1935 }
1936
1937 Ok(names)
1938}
1939
1940#[cfg(feature = "libsql")]
1941async fn query_applied_records_libsql(
1942 conn: &libsql::Connection,
1943 set: &Migrations,
1944) -> Result<Vec<AppliedMigrationRecord>, CliError> {
1945 let sql = format!(
1946 r#"SELECT hash, "name" FROM {} WHERE "name" IS NOT NULL ORDER BY id;"#,
1947 set.table_ident_sql()
1948 );
1949 let Ok(mut rows) = conn.query(&sql, ()).await else {
1950 return Ok(vec![]); };
1952
1953 let mut applied = Vec::new();
1954 while let Ok(Some(row)) = rows.next().await {
1955 if let (Ok(hash), Ok(name)) = (row.get::<String>(0), row.get::<String>(1)) {
1956 applied.push(AppliedMigrationRecord { hash, name });
1957 }
1958 }
1959
1960 Ok(applied)
1961}
1962
1963#[cfg(feature = "turso")]
1968fn execute_turso_statements(
1969 url: &str,
1970 auth_token: Option<&str>,
1971 statements: &[String],
1972) -> Result<(), CliError> {
1973 let rt = tokio::runtime::Builder::new_current_thread()
1974 .enable_all()
1975 .build()
1976 .map_err(|e| CliError::Other(format!("Failed to create async runtime: {e}")))?;
1977
1978 rt.block_on(execute_turso_inner(url, auth_token, statements))
1979}
1980
1981#[cfg(feature = "turso")]
1982async fn execute_turso_inner(
1983 url: &str,
1984 auth_token: Option<&str>,
1985 statements: &[String],
1986) -> Result<(), CliError> {
1987 let builder =
1988 libsql::Builder::new_remote(url.to_string(), auth_token.unwrap_or("").to_string());
1989
1990 let db = builder.build().await.map_err(|e| {
1991 CliError::ConnectionError(format!("Failed to connect to Turso '{url}': {e}"))
1992 })?;
1993
1994 let conn = db
1995 .connect()
1996 .map_err(|e| CliError::ConnectionError(e.to_string()))?;
1997
1998 let tx = conn
1999 .transaction()
2000 .await
2001 .map_err(|e| CliError::MigrationError(e.to_string()))?;
2002
2003 for stmt in statements {
2004 let s = stmt.trim();
2005 if s.is_empty() {
2006 continue;
2007 }
2008 if let Err(e) = tx.execute(s, ()).await {
2009 tx.rollback().await.ok();
2010 return Err(CliError::MigrationError(format!(
2011 "Statement failed: {e}\n{s}"
2012 )));
2013 }
2014 }
2015
2016 tx.commit()
2017 .await
2018 .map_err(|e| CliError::MigrationError(e.to_string()))?;
2019
2020 Ok(())
2021}
2022
2023#[cfg(feature = "turso")]
2024fn run_turso_migrations(
2025 set: &Migrations,
2026 url: &str,
2027 auth_token: Option<&str>,
2028) -> Result<MigrationResult, CliError> {
2029 let rt = tokio::runtime::Builder::new_current_thread()
2030 .enable_all()
2031 .build()
2032 .map_err(|e| CliError::Other(format!("Failed to create async runtime: {e}")))?;
2033
2034 rt.block_on(run_turso_inner(set, url, auth_token))
2035}
2036
2037#[cfg(feature = "turso")]
2038fn inspect_turso_migrations(
2039 set: &Migrations,
2040 url: &str,
2041 auth_token: Option<&str>,
2042) -> Result<MigrationPlan, CliError> {
2043 let rt = tokio::runtime::Builder::new_current_thread()
2044 .enable_all()
2045 .build()
2046 .map_err(|e| CliError::Other(format!("Failed to create async runtime: {e}")))?;
2047
2048 rt.block_on(inspect_turso_inner(set, url, auth_token))
2049}
2050
2051#[cfg(feature = "turso")]
2052async fn inspect_turso_inner(
2053 set: &Migrations,
2054 url: &str,
2055 auth_token: Option<&str>,
2056) -> Result<MigrationPlan, CliError> {
2057 let builder =
2058 libsql::Builder::new_remote(url.to_string(), auth_token.unwrap_or("").to_string());
2059
2060 let db = builder.build().await.map_err(|e| {
2061 CliError::ConnectionError(format!("Failed to connect to Turso '{url}': {e}"))
2062 })?;
2063
2064 let conn = db
2065 .connect()
2066 .map_err(|e| CliError::ConnectionError(e.to_string()))?;
2067
2068 ensure_sqlite_tracking_table_libsql(&conn, set).await?;
2069 let applied = query_applied_records_turso(&conn, set).await?;
2070 build_migration_plan(set, &applied)
2071}
2072
2073#[cfg(feature = "turso")]
2074async fn run_turso_inner(
2075 set: &Migrations,
2076 url: &str,
2077 auth_token: Option<&str>,
2078) -> Result<MigrationResult, CliError> {
2079 let builder =
2080 libsql::Builder::new_remote(url.to_string(), auth_token.unwrap_or("").to_string());
2081
2082 let db = builder.build().await.map_err(|e| {
2083 CliError::ConnectionError(format!("Failed to connect to Turso '{url}': {e}"))
2084 })?;
2085
2086 let conn = db
2087 .connect()
2088 .map_err(|e| CliError::ConnectionError(e.to_string()))?;
2089
2090 ensure_sqlite_tracking_table_libsql(&conn, set).await?;
2091
2092 let applied_names = query_applied_names_turso(&conn, set).await?;
2094
2095 let pending: Vec<_> = set.pending(&applied_names).collect();
2097 if pending.is_empty() {
2098 return Ok(MigrationResult {
2099 applied_count: 0,
2100 applied_migrations: vec![],
2101 });
2102 }
2103
2104 let tx = conn
2106 .transaction()
2107 .await
2108 .map_err(|e| CliError::MigrationError(e.to_string()))?;
2109
2110 let mut applied = Vec::new();
2111 for migration in &pending {
2112 for stmt in migration.statements() {
2113 if !stmt.trim().is_empty()
2114 && let Err(e) = tx.execute(stmt, ()).await
2115 {
2116 tx.rollback().await.ok();
2117 return Err(CliError::MigrationError(format!(
2118 "Migration '{}' failed: {}",
2119 migration.hash(),
2120 e
2121 )));
2122 }
2123 }
2124 if let Err(e) = tx.execute(&set.record_migration_sql(migration), ()).await {
2125 tx.rollback().await.ok();
2126 return Err(CliError::MigrationError(e.to_string()));
2127 }
2128 applied.push(migration.hash().to_string());
2129 }
2130
2131 tx.commit()
2132 .await
2133 .map_err(|e| CliError::MigrationError(e.to_string()))?;
2134
2135 Ok(MigrationResult {
2136 applied_count: applied.len(),
2137 applied_migrations: applied,
2138 })
2139}
2140
2141#[cfg(feature = "turso")]
2142async fn query_applied_names_turso(
2143 conn: &libsql::Connection,
2144 set: &Migrations,
2145) -> Result<Vec<String>, CliError> {
2146 let Ok(mut rows) = conn.query(&set.applied_names_sql(), ()).await else {
2147 return Ok(vec![]); };
2149
2150 let mut names = Vec::new();
2151 while let Ok(Some(row)) = rows.next().await {
2152 if let Ok(name) = row.get::<String>(0) {
2153 names.push(name);
2154 }
2155 }
2156
2157 Ok(names)
2158}
2159
2160#[cfg(feature = "turso")]
2161async fn query_applied_records_turso(
2162 conn: &libsql::Connection,
2163 set: &Migrations,
2164) -> Result<Vec<AppliedMigrationRecord>, CliError> {
2165 let sql = format!(
2166 r#"SELECT hash, "name" FROM {} WHERE "name" IS NOT NULL ORDER BY id;"#,
2167 set.table_ident_sql()
2168 );
2169 let Ok(mut rows) = conn.query(&sql, ()).await else {
2170 return Ok(vec![]); };
2172
2173 let mut applied = Vec::new();
2174 while let Ok(Some(row)) = rows.next().await {
2175 if let (Ok(hash), Ok(name)) = (row.get::<String>(0), row.get::<String>(1)) {
2176 applied.push(AppliedMigrationRecord { hash, name });
2177 }
2178 }
2179
2180 Ok(applied)
2181}
2182
2183#[derive(Debug)]
2189pub struct IntrospectResult {
2190 pub schema_code: String,
2192 pub table_count: usize,
2194 pub index_count: usize,
2196 pub view_count: usize,
2198 pub warnings: Vec<String>,
2200 pub snapshot: Snapshot,
2202 pub snapshot_path: std::path::PathBuf,
2204}
2205
2206#[allow(clippy::too_many_arguments)]
2216pub fn run_introspection(
2217 credentials: &Credentials,
2218 dialect: Dialect,
2219 out_dir: &Path,
2220 init_metadata: bool,
2221 breakpoints: bool,
2222 introspect_casing: Option<IntrospectCasing>,
2223 filters: &SnapshotFilters,
2224 migrations_table: &str,
2225 migrations_schema: &str,
2226) -> Result<IntrospectResult, CliError> {
2227 use drizzle_migrations::words::generate_migration_tag;
2228
2229 let mut result = introspect_database(credentials, dialect)?;
2231 apply_snapshot_filters(&mut result.snapshot, dialect, filters)?;
2232 if !filters.is_empty() || introspect_casing.is_some() {
2233 regenerate_schema_from_snapshot(&mut result, dialect, introspect_casing);
2234 }
2235
2236 let schema_path = out_dir.join("schema.rs");
2238 if let Some(parent) = schema_path.parent() {
2239 std::fs::create_dir_all(parent).map_err(|e| {
2240 CliError::Other(format!(
2241 "Failed to create output directory '{}': {}",
2242 parent.display(),
2243 e
2244 ))
2245 })?;
2246 }
2247 std::fs::write(&schema_path, &result.schema_code).map_err(|e| {
2248 CliError::Other(format!(
2249 "Failed to write schema file '{}': {}",
2250 schema_path.display(),
2251 e
2252 ))
2253 })?;
2254
2255 let journal_path = out_dir.join("meta").join("_journal.json");
2256 if journal_path.exists() {
2257 return Err(CliError::Other(
2258 "Detected old drizzle-kit migration folders. Upgrade them before writing new migrations."
2259 .to_string(),
2260 ));
2261 }
2262
2263 let tag = generate_migration_tag(None);
2265
2266 let migration_dir = out_dir.join(&tag);
2268 std::fs::create_dir_all(&migration_dir).map_err(|e| {
2269 CliError::Other(format!(
2270 "Failed to create migration directory '{}': {}",
2271 migration_dir.display(),
2272 e
2273 ))
2274 })?;
2275
2276 let snapshot_path = migration_dir.join("snapshot.json");
2278 result.snapshot.save(&snapshot_path).map_err(|e| {
2279 CliError::Other(format!(
2280 "Failed to write snapshot file '{}': {}",
2281 snapshot_path.display(),
2282 e
2283 ))
2284 })?;
2285
2286 let base_dialect = dialect.to_base();
2288 let empty_snapshot = Snapshot::empty(base_dialect);
2289 let sql_statements =
2290 generate_introspect_migration(&empty_snapshot, &result.snapshot, breakpoints)?;
2291
2292 let migration_sql_path = migration_dir.join("migration.sql");
2294 let sql_content = format_migration_sql(&sql_statements, breakpoints);
2295 std::fs::write(&migration_sql_path, &sql_content).map_err(|e| {
2296 CliError::Other(format!(
2297 "Failed to write migration file '{}': {}",
2298 migration_sql_path.display(),
2299 e
2300 ))
2301 })?;
2302
2303 result.snapshot_path = snapshot_path;
2305
2306 if init_metadata {
2307 apply_init_metadata(
2308 credentials,
2309 dialect,
2310 out_dir,
2311 migrations_table,
2312 migrations_schema,
2313 )?;
2314 }
2315
2316 Ok(result)
2317}
2318
2319#[allow(unused_variables)] fn apply_init_metadata(
2325 credentials: &Credentials,
2326 dialect: Dialect,
2327 out_dir: &Path,
2328 migrations_table: &str,
2329 migrations_schema: &str,
2330) -> Result<(), CliError> {
2331 #[cfg(any(
2332 feature = "rusqlite",
2333 feature = "libsql",
2334 feature = "turso",
2335 feature = "postgres-sync",
2336 feature = "tokio-postgres",
2337 feature = "d1-http",
2338 ))]
2339 let set = load_migration_set(dialect, out_dir, migrations_table, migrations_schema)?;
2340
2341 match credentials {
2342 #[cfg(feature = "rusqlite")]
2343 Credentials::Sqlite { path } => init_sqlite_metadata(path, &set),
2344
2345 #[cfg(not(feature = "rusqlite"))]
2346 Credentials::Sqlite { .. } => Err(CliError::MissingDriver {
2347 dialect: "SQLite",
2348 feature: "rusqlite",
2349 }),
2350
2351 #[cfg(any(feature = "libsql", feature = "turso"))]
2352 Credentials::Turso { url, auth_token } => {
2353 if is_local_libsql(url) {
2354 #[cfg(feature = "libsql")]
2355 {
2356 init_libsql_local_metadata(url, &set)
2357 }
2358 #[cfg(not(feature = "libsql"))]
2359 {
2360 Err(CliError::MissingDriver {
2361 dialect: "LibSQL (local)",
2362 feature: "libsql",
2363 })
2364 }
2365 } else {
2366 #[cfg(feature = "turso")]
2367 {
2368 init_turso_metadata(url, auth_token.as_deref(), &set)
2369 }
2370 #[cfg(not(feature = "turso"))]
2371 {
2372 let _ = auth_token;
2373 Err(CliError::MissingDriver {
2374 dialect: "Turso (remote)",
2375 feature: "turso",
2376 })
2377 }
2378 }
2379 }
2380
2381 #[cfg(all(not(feature = "turso"), not(feature = "libsql")))]
2382 Credentials::Turso { .. } => Err(CliError::MissingDriver {
2383 dialect: "Turso",
2384 feature: "turso or libsql",
2385 }),
2386
2387 Credentials::Postgres(creds) => {
2388 let _ = creds;
2389 core::cfg_select! {
2390 feature = "postgres-sync" => init_postgres_sync_metadata(creds, &set),
2391 feature = "tokio-postgres" => init_postgres_async_metadata(creds, &set),
2392 _ => Err(CliError::MissingDriver {
2393 dialect: "PostgreSQL",
2394 feature: "postgres-sync or tokio-postgres",
2395 }),
2396 }
2397 }
2398
2399 #[cfg(feature = "d1-http")]
2400 Credentials::D1 {
2401 account_id,
2402 database_id,
2403 token,
2404 } => d1_http::init_metadata(&set, account_id, database_id, token),
2405
2406 #[cfg(not(feature = "d1-http"))]
2407 Credentials::D1 { .. } => Err(CliError::MissingDriver {
2408 dialect: "Cloudflare D1 (HTTP)",
2409 feature: "d1-http",
2410 }),
2411
2412 Credentials::AwsDataApi { .. } => Err(CliError::UnsupportedForDriver {
2413 operation: "Migration metadata init against AWS Data API",
2414 driver: "aws-data-api",
2415 hint: "AWS RDS Data API schema ops are not yet wired into this CLI. Seed the \
2416 migrations table manually via `aws rds-data execute-statement` or \
2417 tokio-postgres.",
2418 }),
2419 }
2420}
2421
2422#[cfg(any(
2423 feature = "rusqlite",
2424 feature = "libsql",
2425 feature = "turso",
2426 feature = "postgres-sync",
2427 feature = "tokio-postgres",
2428 feature = "d1-http",
2429))]
2430pub(crate) fn validate_init_metadata(
2431 applied_names: &[String],
2432 set: &Migrations,
2433) -> Result<(), CliError> {
2434 if !applied_names.is_empty() {
2435 return Err(CliError::Other(
2436 "--init can't be used when database already has migrations set".into(),
2437 ));
2438 }
2439
2440 if set.all().len() > 1 {
2441 return Err(CliError::Other(
2442 "--init can't be used with existing migrations".into(),
2443 ));
2444 }
2445
2446 Ok(())
2447}
2448
2449#[cfg(feature = "rusqlite")]
2454fn init_sqlite_metadata(path: &str, set: &Migrations) -> Result<(), CliError> {
2455 let conn = rusqlite::Connection::open(path).map_err(|e| {
2456 CliError::ConnectionError(format!("Failed to open SQLite database '{path}': {e}"))
2457 })?;
2458
2459 ensure_sqlite_tracking_table(&conn, set)?;
2460
2461 let applied_names = query_applied_names_sqlite(&conn, set)?;
2462 validate_init_metadata(&applied_names, set)?;
2463
2464 let Some(first) = set.all().first() else {
2465 return Ok(());
2466 };
2467
2468 conn.execute(&set.record_migration_sql(first), [])
2469 .map_err(|e| CliError::MigrationError(e.to_string()))?;
2470
2471 Ok(())
2472}
2473
2474#[cfg(feature = "libsql")]
2475fn init_libsql_local_metadata(path: &str, set: &Migrations) -> Result<(), CliError> {
2476 let rt = tokio::runtime::Builder::new_current_thread()
2477 .enable_all()
2478 .build()
2479 .map_err(|e| CliError::Other(format!("Failed to create async runtime: {e}")))?;
2480
2481 rt.block_on(init_libsql_local_metadata_inner(path, set))
2482}
2483
2484#[cfg(feature = "libsql")]
2485async fn init_libsql_local_metadata_inner(path: &str, set: &Migrations) -> Result<(), CliError> {
2486 let db = libsql::Builder::new_local(path)
2487 .build()
2488 .await
2489 .map_err(|e| {
2490 CliError::ConnectionError(format!("Failed to open LibSQL database '{path}': {e}"))
2491 })?;
2492
2493 let conn = db
2494 .connect()
2495 .map_err(|e| CliError::ConnectionError(e.to_string()))?;
2496
2497 ensure_sqlite_tracking_table_libsql(&conn, set).await?;
2498
2499 let applied_names = query_applied_names_libsql(&conn, set).await?;
2500 validate_init_metadata(&applied_names, set)?;
2501
2502 let Some(first) = set.all().first() else {
2503 return Ok(());
2504 };
2505
2506 conn.execute(&set.record_migration_sql(first), ())
2507 .await
2508 .map_err(|e| CliError::MigrationError(e.to_string()))?;
2509
2510 Ok(())
2511}
2512
2513#[cfg(feature = "turso")]
2514fn init_turso_metadata(
2515 url: &str,
2516 auth_token: Option<&str>,
2517 set: &Migrations,
2518) -> Result<(), CliError> {
2519 let rt = tokio::runtime::Builder::new_current_thread()
2520 .enable_all()
2521 .build()
2522 .map_err(|e| CliError::Other(format!("Failed to create async runtime: {e}")))?;
2523
2524 rt.block_on(init_turso_metadata_inner(url, auth_token, set))
2525}
2526
2527#[cfg(feature = "turso")]
2528async fn init_turso_metadata_inner(
2529 url: &str,
2530 auth_token: Option<&str>,
2531 set: &Migrations,
2532) -> Result<(), CliError> {
2533 let builder =
2534 libsql::Builder::new_remote(url.to_string(), auth_token.unwrap_or("").to_string());
2535
2536 let db = builder.build().await.map_err(|e| {
2537 CliError::ConnectionError(format!("Failed to connect to Turso '{url}': {e}"))
2538 })?;
2539
2540 let conn = db
2541 .connect()
2542 .map_err(|e| CliError::ConnectionError(e.to_string()))?;
2543
2544 ensure_sqlite_tracking_table_libsql(&conn, set).await?;
2545
2546 let applied_names = query_applied_names_turso(&conn, set).await?;
2547 validate_init_metadata(&applied_names, set)?;
2548
2549 let Some(first) = set.all().first() else {
2550 return Ok(());
2551 };
2552
2553 conn.execute(&set.record_migration_sql(first), ())
2554 .await
2555 .map_err(|e| CliError::MigrationError(e.to_string()))?;
2556
2557 Ok(())
2558}
2559
2560#[cfg(feature = "postgres-sync")]
2561fn init_postgres_sync_metadata(creds: &PostgresCreds, set: &Migrations) -> Result<(), CliError> {
2562 let url = creds.connection_url();
2563 let mut client = postgres::Client::connect(&url, postgres::NoTls)
2564 .map_err(|e| CliError::ConnectionError(format!("Failed to connect to PostgreSQL: {e}")))?;
2565
2566 if let Some(schema_sql) = set.create_schema_sql() {
2567 client
2568 .execute(&schema_sql, &[])
2569 .map_err(|e| CliError::MigrationError(e.to_string()))?;
2570 }
2571
2572 ensure_postgres_tracking_table_sync(&mut client, set)?;
2573
2574 let rows = client
2575 .query(&set.applied_names_sql(), &[])
2576 .unwrap_or_default();
2577 let applied_names: Vec<String> = rows.iter().filter_map(|r| r.try_get(0).ok()).collect();
2578
2579 validate_init_metadata(&applied_names, set)?;
2580
2581 let Some(first) = set.all().first() else {
2582 return Ok(());
2583 };
2584
2585 client
2586 .execute(&set.record_migration_sql(first), &[])
2587 .map_err(|e| CliError::MigrationError(e.to_string()))?;
2588
2589 Ok(())
2590}
2591
2592#[cfg(all(feature = "tokio-postgres", not(feature = "postgres-sync")))]
2593fn init_postgres_async_metadata(creds: &PostgresCreds, set: &Migrations) -> Result<(), CliError> {
2594 let rt = tokio::runtime::Builder::new_current_thread()
2595 .enable_all()
2596 .build()
2597 .map_err(|e| CliError::Other(format!("Failed to create async runtime: {}", e)))?;
2598
2599 rt.block_on(init_postgres_async_inner(creds, set))
2600}
2601
2602#[cfg(all(feature = "tokio-postgres", not(feature = "postgres-sync")))]
2603async fn init_postgres_async_inner(
2604 creds: &PostgresCreds,
2605 set: &Migrations,
2606) -> Result<(), CliError> {
2607 let url = creds.connection_url();
2608 let (client, connection) = tokio_postgres::connect(&url, tokio_postgres::NoTls)
2609 .await
2610 .map_err(|e| {
2611 CliError::ConnectionError(format!("Failed to connect to PostgreSQL: {}", e))
2612 })?;
2613
2614 tokio::spawn(async move {
2615 if let Err(e) = connection.await {
2616 eprintln!(
2617 "{}",
2618 output::err_line(&format!("PostgreSQL connection error: {e}"))
2619 );
2620 }
2621 });
2622
2623 if let Some(schema_sql) = set.create_schema_sql() {
2624 client
2625 .execute(&schema_sql, &[])
2626 .await
2627 .map_err(|e| CliError::MigrationError(e.to_string()))?;
2628 }
2629
2630 ensure_postgres_tracking_table_async(&client, set).await?;
2631
2632 let rows = client
2633 .query(&set.applied_names_sql(), &[])
2634 .await
2635 .unwrap_or_default();
2636 let applied_names: Vec<String> = rows.iter().filter_map(|r| r.try_get(0).ok()).collect();
2637
2638 validate_init_metadata(&applied_names, set)?;
2639
2640 let Some(first) = set.all().first() else {
2641 return Ok(());
2642 };
2643
2644 client
2645 .execute(&set.record_migration_sql(first), &[])
2646 .await
2647 .map_err(|e| CliError::MigrationError(e.to_string()))?;
2648
2649 Ok(())
2650}
2651
2652fn generate_introspect_migration(
2654 prev: &Snapshot,
2655 current: &Snapshot,
2656 breakpoints: bool,
2657) -> Result<Vec<String>, CliError> {
2658 match (prev, current) {
2659 (Snapshot::Sqlite(prev_snap), Snapshot::Sqlite(curr_snap)) => {
2660 use drizzle_migrations::sqlite::diff_snapshots;
2661 use drizzle_migrations::sqlite::statements::SqliteGenerator;
2662
2663 let diff = diff_snapshots(prev_snap, curr_snap);
2664 let generator = SqliteGenerator::new().with_breakpoints(breakpoints);
2665 Ok(generator.generate_migration(&diff))
2666 }
2667 (Snapshot::Postgres(prev_snap), Snapshot::Postgres(curr_snap)) => {
2668 use drizzle_migrations::postgres::diff_full_snapshots;
2669 use drizzle_migrations::postgres::statements::PostgresGenerator;
2670
2671 let diff = diff_full_snapshots(prev_snap, curr_snap);
2672 let generator = PostgresGenerator::new().with_breakpoints(breakpoints);
2673 Ok(generator.generate(&diff.diffs))
2674 }
2675 _ => Err(CliError::DialectMismatch),
2676 }
2677}
2678
2679fn format_migration_sql(sql_statements: &[String], breakpoints: bool) -> String {
2680 if sql_statements.is_empty() {
2681 "-- No tables to create (empty database)\n".to_string()
2682 } else if breakpoints {
2683 sql_statements.join("\n--> statement-breakpoint\n")
2684 } else {
2685 sql_statements.join("\n\n")
2686 }
2687}
2688
2689pub fn apply_snapshot_filters(
2697 snapshot: &mut Snapshot,
2698 dialect: Dialect,
2699 filters: &SnapshotFilters,
2700) -> Result<(), CliError> {
2701 if filters.is_empty() {
2702 return Ok(());
2703 }
2704
2705 match (dialect, snapshot) {
2706 (Dialect::Sqlite | Dialect::Turso, Snapshot::Sqlite(sqlite)) => {
2707 apply_sqlite_snapshot_filters(sqlite, filters)
2708 }
2709 (Dialect::Postgresql, Snapshot::Postgres(postgres)) => {
2710 apply_postgres_snapshot_filters(postgres, filters)
2711 }
2712 _ => Err(CliError::DialectMismatch),
2713 }
2714}
2715
2716fn apply_sqlite_snapshot_filters(
2717 snapshot: &mut drizzle_migrations::sqlite::SQLiteSnapshot,
2718 filters: &SnapshotFilters,
2719) -> Result<(), CliError> {
2720 use drizzle_types::sqlite::ddl::SqliteEntity;
2721 use std::collections::HashSet;
2722
2723 let table_patterns = compile_patterns(filters.tables.as_deref())?;
2724 if table_patterns.is_none() {
2725 return Ok(());
2726 }
2727
2728 let mut keep_tables: HashSet<String> = HashSet::new();
2729 for entity in &snapshot.ddl {
2730 if let SqliteEntity::Table(table) = entity
2731 && matches_patterns(table.name.as_ref(), table_patterns.as_deref())
2732 {
2733 keep_tables.insert(table.name.to_string());
2734 }
2735 }
2736
2737 snapshot.ddl.retain(|entity| match entity {
2738 SqliteEntity::Table(t) => keep_tables.contains(t.name.as_ref()),
2739 SqliteEntity::Column(c) => keep_tables.contains(c.table.as_ref()),
2740 SqliteEntity::Index(i) => keep_tables.contains(i.table.as_ref()),
2741 SqliteEntity::ForeignKey(fk) => {
2742 keep_tables.contains(fk.table.as_ref()) && keep_tables.contains(fk.table_to.as_ref())
2743 }
2744 SqliteEntity::PrimaryKey(pk) => keep_tables.contains(pk.table.as_ref()),
2745 SqliteEntity::UniqueConstraint(u) => keep_tables.contains(u.table.as_ref()),
2746 SqliteEntity::CheckConstraint(c) => keep_tables.contains(c.table.as_ref()),
2747 SqliteEntity::View(v) => matches_patterns(v.name.as_ref(), table_patterns.as_deref()),
2748 });
2749
2750 Ok(())
2751}
2752
2753fn apply_postgres_snapshot_filters(
2754 snapshot: &mut drizzle_migrations::postgres::PostgresSnapshot,
2755 filters: &SnapshotFilters,
2756) -> Result<(), CliError> {
2757 use drizzle_types::postgres::ddl::PostgresEntity;
2758 use std::collections::HashSet;
2759
2760 let schema_patterns = compile_patterns(filters.schemas.as_deref())?;
2761 let table_patterns = compile_patterns(filters.tables.as_deref())?;
2762 let exclude_postgis = filters
2763 .extensions
2764 .as_ref()
2765 .is_some_and(|v| v.contains(&Extension::Postgis));
2766
2767 let is_schema_allowed = |schema: &str| -> bool {
2768 if exclude_postgis && matches!(schema, "topology" | "tiger" | "tiger_data") {
2769 return false;
2770 }
2771 matches_patterns(schema, schema_patterns.as_deref())
2772 };
2773
2774 let mut keep_tables: HashSet<(String, String)> = HashSet::new();
2775 for entity in &snapshot.ddl {
2776 if let PostgresEntity::Table(table) = entity {
2777 let schema = table.schema.as_ref();
2778 let name = table.name.as_ref();
2779 if !is_schema_allowed(schema) {
2780 continue;
2781 }
2782 if exclude_postgis
2783 && matches!(
2784 name,
2785 "spatial_ref_sys"
2786 | "geometry_columns"
2787 | "geography_columns"
2788 | "raster_columns"
2789 | "raster_overviews"
2790 )
2791 {
2792 continue;
2793 }
2794
2795 if matches_patterns(name, table_patterns.as_deref()) {
2796 keep_tables.insert((schema.to_string(), name.to_string()));
2797 }
2798 }
2799 }
2800
2801 let mut keep_schemas: HashSet<String> = keep_tables.iter().map(|(s, _)| s.clone()).collect();
2802 if table_patterns.is_none() {
2803 for entity in &snapshot.ddl {
2804 if let PostgresEntity::Schema(s) = entity
2805 && is_schema_allowed(s.name.as_ref())
2806 {
2807 keep_schemas.insert(s.name.to_string());
2808 }
2809 }
2810 }
2811
2812 snapshot.ddl.retain(|entity| match entity {
2813 PostgresEntity::Schema(s) => keep_schemas.contains(s.name.as_ref()),
2814 PostgresEntity::Enum(e) => keep_schemas.contains(e.schema.as_ref()),
2815 PostgresEntity::Sequence(s) => keep_schemas.contains(s.schema.as_ref()),
2816 PostgresEntity::Role(_) | PostgresEntity::Privilege(_) => true,
2817 PostgresEntity::Policy(p) => {
2818 keep_tables.contains(&(p.schema.to_string(), p.table.to_string()))
2819 }
2820 PostgresEntity::Table(t) => {
2821 keep_tables.contains(&(t.schema.to_string(), t.name.to_string()))
2822 }
2823 PostgresEntity::Column(c) => {
2824 keep_tables.contains(&(c.schema.to_string(), c.table.to_string()))
2825 }
2826 PostgresEntity::Index(i) => {
2827 keep_tables.contains(&(i.schema.to_string(), i.table.to_string()))
2828 }
2829 PostgresEntity::ForeignKey(fk) => {
2830 keep_tables.contains(&(fk.schema.to_string(), fk.table.to_string()))
2831 && keep_tables.contains(&(fk.schema_to.to_string(), fk.table_to.to_string()))
2832 }
2833 PostgresEntity::PrimaryKey(pk) => {
2834 keep_tables.contains(&(pk.schema.to_string(), pk.table.to_string()))
2835 }
2836 PostgresEntity::UniqueConstraint(u) => {
2837 keep_tables.contains(&(u.schema.to_string(), u.table.to_string()))
2838 }
2839 PostgresEntity::CheckConstraint(c) => {
2840 keep_tables.contains(&(c.schema.to_string(), c.table.to_string()))
2841 }
2842 PostgresEntity::View(v) => {
2843 if !keep_schemas.contains(v.schema.as_ref()) {
2844 return false;
2845 }
2846 matches_patterns(v.name.as_ref(), table_patterns.as_deref())
2847 }
2848 });
2849
2850 Ok(())
2851}
2852
2853#[derive(Debug, Clone)]
2854struct FilterPattern {
2855 pattern: glob::Pattern,
2856 negated: bool,
2857}
2858
2859fn compile_patterns(patterns: Option<&[String]>) -> Result<Option<Vec<FilterPattern>>, CliError> {
2860 let Some(patterns) = patterns else {
2861 return Ok(None);
2862 };
2863 if patterns.is_empty() {
2864 return Ok(None);
2865 }
2866
2867 let mut compiled = Vec::with_capacity(patterns.len());
2868 for p in patterns {
2869 let raw = p.trim();
2870 let (negated, source) = raw
2871 .strip_prefix('!')
2872 .map_or((false, raw), |stripped| (true, stripped));
2873 if source.is_empty() {
2874 return Err(CliError::Other(format!(
2875 "invalid filter pattern '{p}': empty pattern"
2876 )));
2877 }
2878
2879 compiled.push(FilterPattern {
2880 pattern: glob::Pattern::new(source)
2881 .map_err(|e| CliError::Other(format!("invalid filter pattern '{p}': {e}")))?,
2882 negated,
2883 });
2884 }
2885 Ok(Some(compiled))
2886}
2887
2888fn matches_patterns(value: &str, patterns: Option<&[FilterPattern]>) -> bool {
2889 match patterns {
2890 None => true,
2891 Some(v) => {
2892 let has_positive = v.iter().any(|m| !m.negated);
2893 let mut matched_positive = false;
2894
2895 for matcher in v {
2896 if matcher.negated {
2897 if matcher.pattern.matches(value) {
2898 return false;
2899 }
2900 } else if matcher.pattern.matches(value) {
2901 matched_positive = true;
2902 }
2903 }
2904
2905 if has_positive { matched_positive } else { true }
2906 }
2907 }
2908}
2909
2910fn regenerate_schema_from_snapshot(
2911 result: &mut IntrospectResult,
2912 dialect: Dialect,
2913 introspect_casing: Option<IntrospectCasing>,
2914) {
2915 match (&result.snapshot, dialect) {
2916 (Snapshot::Sqlite(snap), Dialect::Sqlite | Dialect::Turso) => {
2917 use drizzle_migrations::sqlite::SQLiteDDL;
2918 use drizzle_migrations::sqlite::codegen::{
2919 CodegenOptions, FieldCasing, generate_rust_schema,
2920 };
2921
2922 let field_casing = match introspect_casing {
2923 Some(IntrospectCasing::Camel) => FieldCasing::Camel,
2924 Some(IntrospectCasing::Preserve) => FieldCasing::Preserve,
2925 None => FieldCasing::Snake,
2926 };
2927
2928 let ddl = SQLiteDDL::from_entities(snap.ddl.clone());
2929 let generated = generate_rust_schema(
2930 &ddl,
2931 &CodegenOptions {
2932 module_doc: Some("Schema introspected from filtered database objects".into()),
2933 include_schema: true,
2934 schema_name: "Schema".into(),
2935 use_pub: true,
2936 field_casing,
2937 },
2938 );
2939
2940 result.schema_code = generated.code;
2941 result.table_count = generated.tables.len();
2942 result.index_count = generated.indexes.len();
2943 result.view_count = ddl.views.list().len();
2944 result.warnings = generated.warnings;
2945 }
2946 (Snapshot::Postgres(snap), Dialect::Postgresql) => {
2947 use drizzle_migrations::postgres::PostgresDDL;
2948 use drizzle_migrations::postgres::codegen::{
2949 CodegenOptions, FieldCasing, generate_rust_schema,
2950 };
2951
2952 let field_casing = match introspect_casing {
2953 Some(IntrospectCasing::Camel) => FieldCasing::Camel,
2954 Some(IntrospectCasing::Preserve) => FieldCasing::Preserve,
2955 None => FieldCasing::Snake,
2956 };
2957
2958 let ddl = PostgresDDL::from_entities(snap.ddl.clone());
2959 let generated = generate_rust_schema(
2960 &ddl,
2961 &CodegenOptions {
2962 module_doc: Some("Schema introspected from filtered database objects".into()),
2963 include_schema: true,
2964 schema_name: "Schema".into(),
2965 use_pub: true,
2966 field_casing,
2967 },
2968 );
2969
2970 result.schema_code = generated.code;
2971 result.table_count = generated.tables.len();
2972 result.index_count = generated.indexes.len();
2973 result.view_count = generated.views.len();
2974 result.warnings = generated.warnings;
2975 }
2976 _ => {}
2977 }
2978}
2979
2980fn introspect_database(
2982 credentials: &Credentials,
2983 dialect: Dialect,
2984) -> Result<IntrospectResult, CliError> {
2985 match dialect {
2986 Dialect::Sqlite | Dialect::Turso => introspect_sqlite_dialect(credentials),
2987 Dialect::Postgresql => introspect_postgres_dialect(credentials),
2988 }
2989}
2990
2991fn introspect_sqlite_dialect(credentials: &Credentials) -> Result<IntrospectResult, CliError> {
2993 match credentials {
2994 #[cfg(feature = "rusqlite")]
2995 Credentials::Sqlite { path } => introspect_rusqlite(path),
2996
2997 #[cfg(not(feature = "rusqlite"))]
2998 Credentials::Sqlite { .. } => Err(CliError::MissingDriver {
2999 dialect: "SQLite",
3000 feature: "rusqlite",
3001 }),
3002
3003 #[cfg(any(feature = "libsql", feature = "turso"))]
3004 Credentials::Turso { url, auth_token } => {
3005 if is_local_libsql(url) {
3006 #[cfg(feature = "libsql")]
3007 {
3008 introspect_libsql_local(url)
3009 }
3010 #[cfg(not(feature = "libsql"))]
3011 {
3012 Err(CliError::MissingDriver {
3013 dialect: "LibSQL (local)",
3014 feature: "libsql",
3015 })
3016 }
3017 } else {
3018 #[cfg(feature = "turso")]
3019 {
3020 introspect_turso(url, auth_token.as_deref())
3021 }
3022 #[cfg(not(feature = "turso"))]
3023 {
3024 let _ = auth_token;
3025 Err(CliError::MissingDriver {
3026 dialect: "Turso (remote)",
3027 feature: "turso",
3028 })
3029 }
3030 }
3031 }
3032
3033 #[cfg(all(not(feature = "turso"), not(feature = "libsql")))]
3034 Credentials::Turso { .. } => Err(CliError::MissingDriver {
3035 dialect: "Turso",
3036 feature: "turso or libsql",
3037 }),
3038
3039 _ => Err(CliError::Other(
3040 "SQLite introspection requires sqlite path or turso credentials".into(),
3041 )),
3042 }
3043}
3044
3045fn introspect_postgres_dialect(credentials: &Credentials) -> Result<IntrospectResult, CliError> {
3047 match credentials {
3048 Credentials::Postgres(creds) => {
3049 let _ = creds;
3050 core::cfg_select! {
3051 feature = "postgres-sync" => introspect_postgres_sync(creds),
3052 feature = "tokio-postgres" => introspect_postgres_async(creds),
3053 _ => Err(CliError::MissingDriver {
3054 dialect: "PostgreSQL",
3055 feature: "postgres-sync or tokio-postgres",
3056 }),
3057 }
3058 }
3059
3060 _ => Err(CliError::Other(
3061 "PostgreSQL introspection requires postgres credentials".into(),
3062 )),
3063 }
3064}
3065
3066#[cfg(any(feature = "rusqlite", feature = "libsql", feature = "turso"))]
3071struct SqliteRawData {
3072 tables: Vec<(String, Option<String>)>,
3073 raw_columns: Vec<drizzle_migrations::sqlite::introspect::RawColumnInfo>,
3074 all_indexes: Vec<drizzle_migrations::sqlite::introspect::RawIndexInfo>,
3075 all_index_columns: Vec<drizzle_migrations::sqlite::introspect::RawIndexColumn>,
3076 all_fks: Vec<drizzle_migrations::sqlite::introspect::RawForeignKey>,
3077 all_views: Vec<drizzle_migrations::sqlite::introspect::RawViewInfo>,
3078}
3079
3080#[cfg(any(feature = "rusqlite", feature = "libsql", feature = "turso"))]
3081impl SqliteRawData {
3082 const fn empty() -> Self {
3083 Self {
3084 tables: Vec::new(),
3085 raw_columns: Vec::new(),
3086 all_indexes: Vec::new(),
3087 all_index_columns: Vec::new(),
3088 all_fks: Vec::new(),
3089 all_views: Vec::new(),
3090 }
3091 }
3092}
3093
3094#[cfg(feature = "rusqlite")]
3095fn query_rusqlite_tables_and_columns(
3096 conn: &rusqlite::Connection,
3097 raw: &mut SqliteRawData,
3098) -> Result<(), CliError> {
3099 use drizzle_migrations::sqlite::introspect::{RawColumnInfo, queries};
3100
3101 let mut tables_stmt = conn
3102 .prepare(queries::TABLES_QUERY)
3103 .map_err(|e| CliError::Other(format!("Failed to prepare tables query: {e}")))?;
3104
3105 raw.tables = tables_stmt
3106 .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
3107 .map_err(|e| CliError::Other(e.to_string()))?
3108 .filter_map(Result::ok)
3109 .collect();
3110
3111 let mut columns_stmt = conn
3112 .prepare(queries::COLUMNS_QUERY)
3113 .map_err(|e| CliError::Other(format!("Failed to prepare columns query: {e}")))?;
3114
3115 raw.raw_columns = columns_stmt
3116 .query_map([], |row| {
3117 Ok(RawColumnInfo {
3118 table: row.get(0)?,
3119 cid: row.get(1)?,
3120 name: row.get(2)?,
3121 column_type: row.get(3)?,
3122 not_null: row.get(4)?,
3123 default_value: row.get(5)?,
3124 pk: row.get(6)?,
3125 hidden: row.get(7)?,
3126 sql: row.get(8)?,
3127 })
3128 })
3129 .map_err(|e| CliError::Other(e.to_string()))?
3130 .filter_map(Result::ok)
3131 .collect();
3132
3133 Ok(())
3134}
3135
3136#[cfg(feature = "rusqlite")]
3137fn query_rusqlite_per_table(
3138 conn: &rusqlite::Connection,
3139 raw: &mut SqliteRawData,
3140) -> Result<(), CliError> {
3141 use drizzle_migrations::sqlite::introspect::{
3142 RawForeignKey, RawIndexColumn, RawIndexInfo, queries,
3143 };
3144
3145 let table_names: Vec<String> = raw.tables.iter().map(|(n, _)| n.clone()).collect();
3147
3148 for table_name in &table_names {
3149 if let Ok(mut idx_stmt) = conn.prepare(&queries::indexes_query(table_name)) {
3150 let indexes: Vec<RawIndexInfo> = idx_stmt
3151 .query_map([], |row| {
3152 Ok(RawIndexInfo {
3153 table: table_name.clone(),
3154 name: row.get(1)?,
3155 unique: row.get::<_, i32>(2)? != 0,
3156 origin: row.get(3)?,
3157 partial: row.get::<_, i32>(4)? != 0,
3158 })
3159 })
3160 .map_err(|e| CliError::Other(e.to_string()))?
3161 .filter_map(Result::ok)
3162 .collect();
3163
3164 for idx in &indexes {
3165 if let Ok(mut col_stmt) = conn.prepare(&queries::index_info_query(&idx.name))
3166 && let Ok(col_iter) = col_stmt.query_map([], |row| {
3167 Ok(RawIndexColumn {
3168 index_name: idx.name.clone(),
3169 seqno: row.get(0)?,
3170 cid: row.get(1)?,
3171 name: row.get(2)?,
3172 desc: row.get::<_, i32>(3)? != 0,
3173 coll: row.get(4)?,
3174 key: row.get::<_, i32>(5)? != 0,
3175 })
3176 })
3177 {
3178 raw.all_index_columns
3179 .extend(col_iter.filter_map(Result::ok));
3180 }
3181 }
3182 raw.all_indexes.extend(indexes);
3183 }
3184
3185 if let Ok(mut fk_stmt) = conn.prepare(&queries::foreign_keys_query(table_name))
3186 && let Ok(fk_iter) = fk_stmt.query_map([], |row| {
3187 Ok(RawForeignKey {
3188 table: table_name.clone(),
3189 id: row.get(0)?,
3190 seq: row.get(1)?,
3191 to_table: row.get(2)?,
3192 from_column: row.get(3)?,
3193 to_column: row.get(4)?,
3194 on_update: row.get(5)?,
3195 on_delete: row.get(6)?,
3196 r#match: row.get(7)?,
3197 })
3198 })
3199 {
3200 raw.all_fks.extend(fk_iter.filter_map(Result::ok));
3201 }
3202 }
3203
3204 Ok(())
3205}
3206
3207#[cfg(feature = "rusqlite")]
3208fn query_rusqlite_views_and_view_columns(conn: &rusqlite::Connection, raw: &mut SqliteRawData) {
3209 use drizzle_migrations::sqlite::introspect::{RawColumnInfo, RawViewInfo, queries};
3210
3211 if let Ok(mut views_stmt) = conn.prepare(queries::VIEWS_QUERY)
3212 && let Ok(view_iter) = views_stmt.query_map([], |row| {
3213 Ok(RawViewInfo {
3214 name: row.get(0)?,
3215 sql: row.get(1)?,
3216 })
3217 })
3218 {
3219 raw.all_views.extend(view_iter.filter_map(Result::ok));
3220 }
3221
3222 if let Ok(mut view_cols_stmt) = conn.prepare(queries::VIEW_COLUMNS_QUERY)
3223 && let Ok(col_iter) = view_cols_stmt.query_map([], |row| {
3224 Ok(RawColumnInfo {
3225 table: row.get(0)?,
3226 cid: row.get(1)?,
3227 name: row.get(2)?,
3228 column_type: row.get(3)?,
3229 not_null: row.get::<_, i32>(4)? != 0,
3230 default_value: row.get(5)?,
3231 pk: row.get(6)?,
3232 hidden: row.get(7)?,
3233 sql: row.get(8)?,
3234 })
3235 })
3236 {
3237 raw.raw_columns.extend(col_iter.filter_map(Result::ok));
3238 }
3239}
3240
3241#[cfg(feature = "rusqlite")]
3242fn query_rusqlite_raw(conn: &rusqlite::Connection) -> Result<SqliteRawData, CliError> {
3243 let mut raw = SqliteRawData::empty();
3244 query_rusqlite_tables_and_columns(conn, &mut raw)?;
3245 query_rusqlite_per_table(conn, &mut raw)?;
3246 query_rusqlite_views_and_view_columns(conn, &mut raw);
3247 Ok(raw)
3248}
3249
3250#[cfg(any(feature = "rusqlite", feature = "libsql", feature = "turso"))]
3251fn build_sqlite_ddl(raw: SqliteRawData) -> drizzle_migrations::sqlite::SQLiteDDL {
3252 use drizzle_migrations::sqlite::{
3253 SQLiteDDL, Table, View,
3254 introspect::{
3255 parse_generated_columns_from_table_sql, parse_view_sql, process_columns,
3256 process_foreign_keys, process_indexes,
3257 },
3258 };
3259 use std::collections::{HashMap, HashSet};
3260
3261 let table_sql_map: HashMap<String, String> = raw
3262 .tables
3263 .iter()
3264 .filter_map(|(name, sql)| sql.as_ref().map(|s| (name.clone(), s.clone())))
3265 .collect();
3266
3267 let mut generated_columns: HashMap<String, drizzle_migrations::sqlite::ddl::ParsedGenerated> =
3268 HashMap::new();
3269 for (table, sql) in &table_sql_map {
3270 generated_columns.extend(parse_generated_columns_from_table_sql(table, sql));
3271 }
3272 let pk_columns: HashSet<(String, String)> = raw
3273 .raw_columns
3274 .iter()
3275 .filter(|c| c.pk > 0)
3276 .map(|c| (c.table.clone(), c.name.clone()))
3277 .collect();
3278
3279 let (columns, primary_keys) =
3280 process_columns(&raw.raw_columns, &generated_columns, &pk_columns);
3281 let indexes = process_indexes(&raw.all_indexes, &raw.all_index_columns, &table_sql_map);
3282 let foreign_keys = process_foreign_keys(&raw.all_fks);
3283 let uniques = process_sqlite_uniques_from_indexes(&raw.all_indexes, &raw.all_index_columns);
3284
3285 let mut ddl = SQLiteDDL::new();
3286
3287 for (table_name, table_sql) in &raw.tables {
3288 let mut table = Table::new(table_name.clone());
3289 if let Some(sql) = table_sql {
3290 let sql_upper = sql.to_uppercase();
3291 table.strict = sql_upper.contains(" STRICT");
3292 table.without_rowid = sql_upper.contains("WITHOUT ROWID");
3293 }
3294 ddl.tables.push(table);
3295 }
3296
3297 for col in columns {
3298 ddl.columns.push(col);
3299 }
3300 for idx in indexes {
3301 ddl.indexes.push(idx);
3302 }
3303 for fk in foreign_keys {
3304 ddl.fks.push(fk);
3305 }
3306 for pk in primary_keys {
3307 ddl.pks.push(pk);
3308 }
3309 for u in uniques {
3310 ddl.uniques.push(u);
3311 }
3312
3313 for v in raw.all_views {
3314 let mut view = View::new(v.name);
3315 if let Some(def) = parse_view_sql(&v.sql) {
3316 view.definition = Some(def.into());
3317 } else {
3318 view.error = Some("Failed to parse view SQL".into());
3319 }
3320 ddl.views.push(view);
3321 }
3322
3323 ddl
3324}
3325
3326#[cfg(feature = "rusqlite")]
3327fn introspect_rusqlite(path: &str) -> Result<IntrospectResult, CliError> {
3328 use drizzle_migrations::sqlite::codegen::{CodegenOptions, FieldCasing, generate_rust_schema};
3329
3330 let conn = rusqlite::Connection::open(path).map_err(|e| {
3331 CliError::ConnectionError(format!("Failed to open SQLite database '{path}': {e}"))
3332 })?;
3333
3334 let raw = query_rusqlite_raw(&conn)?;
3335 let ddl = build_sqlite_ddl(raw);
3336
3337 let options = CodegenOptions {
3338 module_doc: Some(format!("Schema introspected from {path}")),
3339 include_schema: true,
3340 schema_name: "Schema".to_string(),
3341 use_pub: true,
3342 field_casing: FieldCasing::default(),
3343 };
3344
3345 let generated = generate_rust_schema(&ddl, &options);
3346
3347 let mut sqlite_snapshot = drizzle_migrations::sqlite::SQLiteSnapshot::new();
3348 for entity in ddl.to_entities() {
3349 sqlite_snapshot.add_entity(entity);
3350 }
3351 let snapshot = Snapshot::Sqlite(sqlite_snapshot);
3352
3353 Ok(IntrospectResult {
3354 schema_code: generated.code,
3355 table_count: generated.tables.len(),
3356 index_count: generated.indexes.len(),
3357 view_count: ddl.views.len(),
3358 warnings: generated.warnings,
3359 snapshot,
3360 snapshot_path: std::path::PathBuf::new(),
3361 })
3362}
3363
3364#[cfg(feature = "libsql")]
3369fn introspect_libsql_local(path: &str) -> Result<IntrospectResult, CliError> {
3370 let rt = tokio::runtime::Builder::new_current_thread()
3371 .enable_all()
3372 .build()
3373 .map_err(|e| CliError::Other(format!("Failed to create async runtime: {e}")))?;
3374
3375 rt.block_on(introspect_libsql_inner(path, None))
3376}
3377
3378#[cfg(any(feature = "libsql", feature = "turso"))]
3379async fn query_libsql_tables_and_columns(
3380 conn: &libsql::Connection,
3381) -> Result<
3382 (
3383 Vec<(String, Option<String>)>,
3384 Vec<drizzle_migrations::sqlite::introspect::RawColumnInfo>,
3385 ),
3386 CliError,
3387> {
3388 use drizzle_migrations::sqlite::introspect::{RawColumnInfo, queries};
3389
3390 let mut tables_rows = conn
3391 .query(queries::TABLES_QUERY, ())
3392 .await
3393 .map_err(|e| CliError::Other(format!("Failed to query tables: {e}")))?;
3394
3395 let mut tables: Vec<(String, Option<String>)> = Vec::new();
3396 while let Ok(Some(row)) = tables_rows.next().await {
3397 let name: String = row.get(0).unwrap_or_default();
3398 let sql: Option<String> = row.get(1).ok();
3399 tables.push((name, sql));
3400 }
3401
3402 let mut columns_rows = conn
3403 .query(queries::COLUMNS_QUERY, ())
3404 .await
3405 .map_err(|e| CliError::Other(format!("Failed to query columns: {e}")))?;
3406
3407 let mut raw_columns: Vec<RawColumnInfo> = Vec::new();
3408 while let Ok(Some(row)) = columns_rows.next().await {
3409 raw_columns.push(RawColumnInfo {
3410 table: row.get(0).unwrap_or_default(),
3411 cid: row.get(1).unwrap_or(0),
3412 name: row.get(2).unwrap_or_default(),
3413 column_type: row.get(3).unwrap_or_default(),
3414 not_null: row.get::<i32>(4).unwrap_or(0) != 0,
3415 default_value: row.get(5).ok(),
3416 pk: row.get(6).unwrap_or(0),
3417 hidden: row.get(7).unwrap_or(0),
3418 sql: row.get(8).ok(),
3419 });
3420 }
3421
3422 Ok((tables, raw_columns))
3423}
3424
3425#[cfg(any(feature = "libsql", feature = "turso"))]
3426async fn query_libsql_per_table(
3427 conn: &libsql::Connection,
3428 tables: &[(String, Option<String>)],
3429) -> (
3430 Vec<drizzle_migrations::sqlite::introspect::RawIndexInfo>,
3431 Vec<drizzle_migrations::sqlite::introspect::RawIndexColumn>,
3432 Vec<drizzle_migrations::sqlite::introspect::RawForeignKey>,
3433) {
3434 use drizzle_migrations::sqlite::introspect::{
3435 RawForeignKey, RawIndexColumn, RawIndexInfo, queries,
3436 };
3437
3438 let mut all_indexes: Vec<RawIndexInfo> = Vec::new();
3439 let mut all_index_columns: Vec<RawIndexColumn> = Vec::new();
3440 let mut all_fks: Vec<RawForeignKey> = Vec::new();
3441
3442 for (table_name, _) in tables {
3443 if let Ok(mut idx_rows) = conn.query(&queries::indexes_query(table_name), ()).await {
3444 while let Ok(Some(row)) = idx_rows.next().await {
3445 let idx = RawIndexInfo {
3446 table: table_name.clone(),
3447 name: row.get(1).unwrap_or_default(),
3448 unique: row.get::<i32>(2).unwrap_or(0) != 0,
3449 origin: row.get(3).unwrap_or_default(),
3450 partial: row.get::<i32>(4).unwrap_or(0) != 0,
3451 };
3452
3453 if let Ok(mut col_rows) =
3454 conn.query(&queries::index_info_query(&idx.name), ()).await
3455 {
3456 while let Ok(Some(col_row)) = col_rows.next().await {
3457 all_index_columns.push(RawIndexColumn {
3458 index_name: idx.name.clone(),
3459 seqno: col_row.get(0).unwrap_or(0),
3460 cid: col_row.get(1).unwrap_or(0),
3461 name: col_row.get(2).ok(),
3462 desc: col_row.get::<i32>(3).unwrap_or(0) != 0,
3463 coll: col_row.get(4).unwrap_or_default(),
3464 key: col_row.get::<i32>(5).unwrap_or(0) != 0,
3465 });
3466 }
3467 }
3468
3469 all_indexes.push(idx);
3470 }
3471 }
3472
3473 if let Ok(mut fk_rows) = conn
3474 .query(&queries::foreign_keys_query(table_name), ())
3475 .await
3476 {
3477 while let Ok(Some(row)) = fk_rows.next().await {
3478 all_fks.push(RawForeignKey {
3479 table: table_name.clone(),
3480 id: row.get(0).unwrap_or(0),
3481 seq: row.get(1).unwrap_or(0),
3482 to_table: row.get(2).unwrap_or_default(),
3483 from_column: row.get(3).unwrap_or_default(),
3484 to_column: row.get(4).unwrap_or_default(),
3485 on_update: row.get(5).unwrap_or_default(),
3486 on_delete: row.get(6).unwrap_or_default(),
3487 r#match: row.get(7).unwrap_or_default(),
3488 });
3489 }
3490 }
3491 }
3492
3493 (all_indexes, all_index_columns, all_fks)
3494}
3495
3496#[cfg(any(feature = "libsql", feature = "turso"))]
3497async fn query_libsql_views_and_view_columns(
3498 conn: &libsql::Connection,
3499 raw_columns: &mut Vec<drizzle_migrations::sqlite::introspect::RawColumnInfo>,
3500) -> Vec<drizzle_migrations::sqlite::introspect::RawViewInfo> {
3501 use drizzle_migrations::sqlite::introspect::{RawColumnInfo, RawViewInfo, queries};
3502
3503 let mut all_views: Vec<RawViewInfo> = Vec::new();
3504 if let Ok(mut views_rows) = conn.query(queries::VIEWS_QUERY, ()).await {
3505 while let Ok(Some(row)) = views_rows.next().await {
3506 let name: String = row.get(0).unwrap_or_default();
3507 let sql: String = row.get(1).unwrap_or_default();
3508 all_views.push(RawViewInfo { name, sql });
3509 }
3510 }
3511
3512 if let Ok(mut view_cols_rows) = conn.query(queries::VIEW_COLUMNS_QUERY, ()).await {
3513 while let Ok(Some(row)) = view_cols_rows.next().await {
3514 raw_columns.push(RawColumnInfo {
3515 table: row.get(0).unwrap_or_default(),
3516 cid: row.get(1).unwrap_or(0),
3517 name: row.get(2).unwrap_or_default(),
3518 column_type: row.get(3).unwrap_or_default(),
3519 not_null: row.get::<i32>(4).unwrap_or(0) != 0,
3520 default_value: row.get(5).ok(),
3521 pk: row.get(6).unwrap_or(0),
3522 hidden: row.get(7).unwrap_or(0),
3523 sql: row.get(8).ok(),
3524 });
3525 }
3526 }
3527
3528 all_views
3529}
3530
3531#[cfg(any(feature = "libsql", feature = "turso"))]
3532async fn query_libsql_raw(conn: &libsql::Connection) -> Result<SqliteRawData, CliError> {
3533 let (tables, mut raw_columns) = query_libsql_tables_and_columns(conn).await?;
3534 let (all_indexes, all_index_columns, all_fks) = query_libsql_per_table(conn, &tables).await;
3535 let all_views = query_libsql_views_and_view_columns(conn, &mut raw_columns).await;
3536
3537 Ok(SqliteRawData {
3538 tables,
3539 raw_columns,
3540 all_indexes,
3541 all_index_columns,
3542 all_fks,
3543 all_views,
3544 })
3545}
3546
3547#[cfg(feature = "libsql")]
3548async fn introspect_libsql_inner(
3549 path: &str,
3550 _auth_token: Option<&str>,
3551) -> Result<IntrospectResult, CliError> {
3552 use drizzle_migrations::sqlite::codegen::{CodegenOptions, FieldCasing, generate_rust_schema};
3553
3554 let db = libsql::Builder::new_local(path)
3555 .build()
3556 .await
3557 .map_err(|e| {
3558 CliError::ConnectionError(format!("Failed to open LibSQL database '{path}': {e}"))
3559 })?;
3560
3561 let conn = db
3562 .connect()
3563 .map_err(|e| CliError::ConnectionError(e.to_string()))?;
3564
3565 let raw = query_libsql_raw(&conn).await?;
3566 let ddl = build_sqlite_ddl(raw);
3567
3568 let options = CodegenOptions {
3569 module_doc: Some(format!("Schema introspected from {path}")),
3570 include_schema: true,
3571 schema_name: "Schema".to_string(),
3572 use_pub: true,
3573 field_casing: FieldCasing::default(),
3574 };
3575
3576 let generated = generate_rust_schema(&ddl, &options);
3577
3578 let mut sqlite_snapshot = drizzle_migrations::sqlite::SQLiteSnapshot::new();
3579 for entity in ddl.to_entities() {
3580 sqlite_snapshot.add_entity(entity);
3581 }
3582 let snapshot = Snapshot::Sqlite(sqlite_snapshot);
3583
3584 Ok(IntrospectResult {
3585 schema_code: generated.code,
3586 table_count: generated.tables.len(),
3587 index_count: generated.indexes.len(),
3588 view_count: ddl.views.len(),
3589 warnings: generated.warnings,
3590 snapshot,
3591 snapshot_path: std::path::PathBuf::new(),
3592 })
3593}
3594
3595#[cfg(feature = "turso")]
3600fn introspect_turso(url: &str, auth_token: Option<&str>) -> Result<IntrospectResult, CliError> {
3601 let rt = tokio::runtime::Builder::new_current_thread()
3602 .enable_all()
3603 .build()
3604 .map_err(|e| CliError::Other(format!("Failed to create async runtime: {e}")))?;
3605
3606 rt.block_on(introspect_turso_inner(url, auth_token))
3607}
3608
3609#[cfg(feature = "turso")]
3610async fn introspect_turso_inner(
3611 url: &str,
3612 auth_token: Option<&str>,
3613) -> Result<IntrospectResult, CliError> {
3614 use drizzle_migrations::sqlite::codegen::{CodegenOptions, FieldCasing, generate_rust_schema};
3615
3616 let builder =
3617 libsql::Builder::new_remote(url.to_string(), auth_token.unwrap_or("").to_string());
3618
3619 let db = builder.build().await.map_err(|e| {
3620 CliError::ConnectionError(format!("Failed to connect to Turso '{url}': {e}"))
3621 })?;
3622
3623 let conn = db
3624 .connect()
3625 .map_err(|e| CliError::ConnectionError(e.to_string()))?;
3626
3627 let raw = query_libsql_raw(&conn).await?;
3628 let ddl = build_sqlite_ddl(raw);
3629
3630 let options = CodegenOptions {
3631 module_doc: Some(format!("Schema introspected from Turso: {url}")),
3632 include_schema: true,
3633 schema_name: "Schema".to_string(),
3634 use_pub: true,
3635 field_casing: FieldCasing::default(),
3636 };
3637
3638 let generated = generate_rust_schema(&ddl, &options);
3639
3640 let mut sqlite_snapshot = drizzle_migrations::sqlite::SQLiteSnapshot::new();
3641 for entity in ddl.to_entities() {
3642 sqlite_snapshot.add_entity(entity);
3643 }
3644 let snapshot = Snapshot::Sqlite(sqlite_snapshot);
3645
3646 Ok(IntrospectResult {
3647 schema_code: generated.code,
3648 table_count: generated.tables.len(),
3649 index_count: generated.indexes.len(),
3650 view_count: ddl.views.len(),
3651 warnings: generated.warnings,
3652 snapshot,
3653 snapshot_path: std::path::PathBuf::new(),
3654 })
3655}
3656
3657#[cfg(feature = "postgres-sync")]
3662fn introspect_postgres_sync(creds: &PostgresCreds) -> Result<IntrospectResult, CliError> {
3663 let url = creds.connection_url();
3664 let mut client = postgres::Client::connect(&url, postgres::NoTls)
3665 .map_err(|e| CliError::ConnectionError(format!("Failed to connect to PostgreSQL: {e}")))?;
3666
3667 let raw = query_postgres_sync_raw(&mut client)?;
3668 let ddl = build_postgres_ddl(raw);
3669
3670 Ok(finalize_postgres_introspection(&ddl, &url))
3671}
3672
3673#[cfg(any(feature = "postgres-sync", feature = "tokio-postgres"))]
3674impl PostgresRawData {
3675 const fn empty() -> Self {
3676 Self {
3677 schemas: Vec::new(),
3678 tables: Vec::new(),
3679 columns: Vec::new(),
3680 enums: Vec::new(),
3681 sequences: Vec::new(),
3682 views: Vec::new(),
3683 indexes: Vec::new(),
3684 foreign_keys: Vec::new(),
3685 primary_keys: Vec::new(),
3686 uniques: Vec::new(),
3687 checks: Vec::new(),
3688 roles: Vec::new(),
3689 policies: Vec::new(),
3690 }
3691 }
3692}
3693
3694#[cfg(feature = "postgres-sync")]
3695fn query_postgres_sync_raw(client: &mut postgres::Client) -> Result<PostgresRawData, CliError> {
3696 let mut raw = PostgresRawData::empty();
3697 query_pg_sync_core(client, &mut raw)?;
3698 query_pg_sync_codegen_meta(client, &mut raw)?;
3699 query_pg_sync_constraints(client, &mut raw)?;
3700 query_pg_sync_security(client, &mut raw)?;
3701 Ok(raw)
3702}
3703
3704#[cfg(feature = "postgres-sync")]
3705fn query_pg_sync_core(
3706 client: &mut postgres::Client,
3707 raw: &mut PostgresRawData,
3708) -> Result<(), CliError> {
3709 use drizzle_migrations::postgres::introspect::{RawColumnInfo, RawTableInfo, queries};
3710
3711 raw.schemas = client
3712 .query(queries::SCHEMAS_QUERY, &[])
3713 .map_err(|e| CliError::Other(format!("Failed to query schemas: {e}")))?
3714 .into_iter()
3715 .map(|row| RawSchemaInfo {
3716 name: row.get::<_, String>(0),
3717 })
3718 .collect();
3719
3720 raw.tables = client
3721 .query(queries::TABLES_QUERY, &[])
3722 .map_err(|e| CliError::Other(format!("Failed to query tables: {e}")))?
3723 .into_iter()
3724 .map(|row| RawTableInfo {
3725 schema: row.get::<_, String>(0),
3726 name: row.get::<_, String>(1),
3727 is_rls_enabled: row.get::<_, bool>(2),
3728 })
3729 .collect();
3730
3731 raw.columns = client
3732 .query(queries::COLUMNS_QUERY, &[])
3733 .map_err(|e| CliError::Other(format!("Failed to query columns: {e}")))?
3734 .into_iter()
3735 .map(|row| RawColumnInfo {
3736 schema: row.get::<_, String>(0),
3737 table: row.get::<_, String>(1),
3738 name: row.get::<_, String>(2),
3739 column_type: row.get::<_, String>(3),
3740 type_schema: row.get::<_, Option<String>>(4),
3741 not_null: row.get::<_, bool>(5),
3742 default_value: row.get::<_, Option<String>>(6),
3743 is_identity: row.get::<_, bool>(7),
3744 identity_type: row.get::<_, Option<String>>(8),
3745 is_generated: row.get::<_, bool>(9),
3746 generated_expression: row.get::<_, Option<String>>(10),
3747 generated_stored: row.get::<_, bool>(11),
3748 ordinal_position: row.get::<_, i32>(12),
3749 })
3750 .collect();
3751
3752 Ok(())
3753}
3754
3755#[cfg(feature = "postgres-sync")]
3756fn query_pg_sync_codegen_meta(
3757 client: &mut postgres::Client,
3758 raw: &mut PostgresRawData,
3759) -> Result<(), CliError> {
3760 use drizzle_migrations::postgres::introspect::{
3761 RawEnumInfo, RawSequenceInfo, RawViewInfo, queries,
3762 };
3763
3764 raw.enums = client
3765 .query(queries::ENUMS_QUERY, &[])
3766 .map_err(|e| CliError::Other(format!("Failed to query enums: {e}")))?
3767 .into_iter()
3768 .map(|row| RawEnumInfo {
3769 schema: row.get::<_, String>(0),
3770 name: row.get::<_, String>(1),
3771 values: row.get::<_, Vec<String>>(2),
3772 })
3773 .collect();
3774
3775 raw.sequences = client
3776 .query(queries::SEQUENCES_QUERY, &[])
3777 .map_err(|e| CliError::Other(format!("Failed to query sequences: {e}")))?
3778 .into_iter()
3779 .map(|row| RawSequenceInfo {
3780 schema: row.get::<_, String>(0),
3781 name: row.get::<_, String>(1),
3782 data_type: row.get::<_, Option<String>>(2),
3783 start_value: row.get::<_, Option<String>>(3),
3784 min_value: row.get::<_, Option<String>>(4),
3785 max_value: row.get::<_, Option<String>>(5),
3786 increment: row.get::<_, Option<String>>(6),
3787 cycle: row.get::<_, Option<bool>>(7),
3788 cache_value: row.get::<_, Option<String>>(8),
3789 })
3790 .collect();
3791
3792 let view_schema_filters: Option<Vec<String>> = None;
3793 raw.views = client
3794 .query(queries::VIEWS_QUERY, &[&view_schema_filters])
3795 .map_err(|e| CliError::Other(format!("Failed to query views: {e}")))?
3796 .into_iter()
3797 .map(|row| RawViewInfo {
3798 schema: row.get::<_, String>(0),
3799 name: row.get::<_, String>(1),
3800 definition: row.get::<_, String>(2),
3801 is_materialized: row.get::<_, bool>(3),
3802 })
3803 .collect();
3804
3805 Ok(())
3806}
3807
3808#[cfg(feature = "postgres-sync")]
3809fn query_pg_sync_constraints(
3810 client: &mut postgres::Client,
3811 raw: &mut PostgresRawData,
3812) -> Result<(), CliError> {
3813 use drizzle_migrations::postgres::introspect::{
3814 RawCheckInfo, RawForeignKeyInfo, RawIndexInfo, RawPrimaryKeyInfo, RawUniqueInfo,
3815 parse_index_columns, pg_action_code_to_string, queries,
3816 };
3817
3818 raw.indexes = client
3819 .query(queries::INDEXES_QUERY, &[])
3820 .map_err(|e| CliError::Other(format!("Failed to query indexes: {e}")))?
3821 .into_iter()
3822 .map(|row| {
3823 let cols: Vec<String> = row.get(6);
3824 RawIndexInfo {
3825 schema: row.get::<_, String>(0),
3826 table: row.get::<_, String>(1),
3827 name: row.get::<_, String>(2),
3828 is_unique: row.get::<_, bool>(3),
3829 is_primary: row.get::<_, bool>(4),
3830 method: row.get::<_, String>(5),
3831 columns: parse_index_columns(cols),
3832 where_clause: row.get::<_, Option<String>>(7),
3833 concurrent: false,
3834 }
3835 })
3836 .collect();
3837
3838 raw.foreign_keys = client
3839 .query(queries::FOREIGN_KEYS_QUERY, &[])
3840 .map_err(|e| CliError::Other(format!("Failed to query foreign keys: {e}")))?
3841 .into_iter()
3842 .map(|row| RawForeignKeyInfo {
3843 schema: row.get::<_, String>(0),
3844 table: row.get::<_, String>(1),
3845 name: row.get::<_, String>(2),
3846 columns: row.get::<_, Vec<String>>(3),
3847 schema_to: row.get::<_, String>(4),
3848 table_to: row.get::<_, String>(5),
3849 columns_to: row.get::<_, Vec<String>>(6),
3850 on_update: pg_action_code_to_string(&row.get::<_, String>(7)),
3851 on_delete: pg_action_code_to_string(&row.get::<_, String>(8)),
3852 })
3853 .collect();
3854
3855 raw.primary_keys = client
3856 .query(queries::PRIMARY_KEYS_QUERY, &[])
3857 .map_err(|e| CliError::Other(format!("Failed to query primary keys: {e}")))?
3858 .into_iter()
3859 .map(|row| RawPrimaryKeyInfo {
3860 schema: row.get::<_, String>(0),
3861 table: row.get::<_, String>(1),
3862 name: row.get::<_, String>(2),
3863 columns: row.get::<_, Vec<String>>(3),
3864 })
3865 .collect();
3866
3867 raw.uniques = client
3868 .query(queries::UNIQUES_QUERY, &[])
3869 .map_err(|e| CliError::Other(format!("Failed to query unique constraints: {e}")))?
3870 .into_iter()
3871 .map(|row| RawUniqueInfo {
3872 schema: row.get::<_, String>(0),
3873 table: row.get::<_, String>(1),
3874 name: row.get::<_, String>(2),
3875 columns: row.get::<_, Vec<String>>(3),
3876 nulls_not_distinct: row.get::<_, bool>(4),
3877 })
3878 .collect();
3879
3880 raw.checks = client
3881 .query(queries::CHECKS_QUERY, &[])
3882 .map_err(|e| CliError::Other(format!("Failed to query check constraints: {e}")))?
3883 .into_iter()
3884 .map(|row| RawCheckInfo {
3885 schema: row.get::<_, String>(0),
3886 table: row.get::<_, String>(1),
3887 name: row.get::<_, String>(2),
3888 expression: row.get::<_, String>(3),
3889 })
3890 .collect();
3891
3892 Ok(())
3893}
3894
3895#[cfg(feature = "postgres-sync")]
3896fn query_pg_sync_security(
3897 client: &mut postgres::Client,
3898 raw: &mut PostgresRawData,
3899) -> Result<(), CliError> {
3900 use drizzle_migrations::postgres::introspect::{RawPolicyInfo, RawRoleInfo, queries};
3901
3902 raw.roles = client
3903 .query(queries::ROLES_QUERY, &[])
3904 .map_err(|e| CliError::Other(format!("Failed to query roles: {e}")))?
3905 .into_iter()
3906 .map(|row| RawRoleInfo {
3907 name: row.get::<_, String>(0),
3908 create_db: row.get::<_, bool>(1),
3909 create_role: row.get::<_, bool>(2),
3910 inherit: row.get::<_, bool>(3),
3911 })
3912 .collect();
3913
3914 raw.policies = client
3915 .query(queries::POLICIES_QUERY, &[])
3916 .map_err(|e| CliError::Other(format!("Failed to query policies: {e}")))?
3917 .into_iter()
3918 .map(|row| RawPolicyInfo {
3919 schema: row.get::<_, String>(0),
3920 table: row.get::<_, String>(1),
3921 name: row.get::<_, String>(2),
3922 as_clause: row.get::<_, String>(3),
3923 for_clause: row.get::<_, String>(4),
3924 to: row.get::<_, Vec<String>>(5),
3925 using: row.get::<_, Option<String>>(6),
3926 with_check: row.get::<_, Option<String>>(7),
3927 })
3928 .collect();
3929
3930 Ok(())
3931}
3932
3933#[cfg(all(not(feature = "postgres-sync"), feature = "tokio-postgres"))]
3934fn introspect_postgres_async(creds: &PostgresCreds) -> Result<IntrospectResult, CliError> {
3935 let rt = tokio::runtime::Builder::new_current_thread()
3936 .enable_all()
3937 .build()
3938 .map_err(|e| CliError::Other(format!("Failed to create async runtime: {e}")))?;
3939
3940 rt.block_on(introspect_postgres_async_inner(creds))
3941}
3942
3943#[cfg(all(not(feature = "postgres-sync"), feature = "tokio-postgres"))]
3944async fn introspect_postgres_async_inner(
3945 creds: &PostgresCreds,
3946) -> Result<IntrospectResult, CliError> {
3947 let url = creds.connection_url();
3948 let (client, connection) = tokio_postgres::connect(&url, tokio_postgres::NoTls)
3949 .await
3950 .map_err(|e| CliError::ConnectionError(format!("Failed to connect to PostgreSQL: {e}")))?;
3951
3952 tokio::spawn(async move {
3953 if let Err(e) = connection.await {
3954 eprintln!(
3955 "{}",
3956 output::err_line(&format!("PostgreSQL connection error: {e}"))
3957 );
3958 }
3959 });
3960
3961 let raw = query_postgres_async_raw(&client).await?;
3962 let ddl = build_postgres_ddl(raw);
3963
3964 Ok(finalize_postgres_introspection(&ddl, &url))
3965}
3966
3967#[cfg(all(not(feature = "postgres-sync"), feature = "tokio-postgres"))]
3968async fn query_postgres_async_raw(
3969 client: &tokio_postgres::Client,
3970) -> Result<PostgresRawData, CliError> {
3971 let mut raw = PostgresRawData::empty();
3972 query_pg_async_core(client, &mut raw).await?;
3973 query_pg_async_codegen_meta(client, &mut raw).await?;
3974 query_pg_async_constraints(client, &mut raw).await?;
3975 query_pg_async_security(client, &mut raw).await?;
3976 Ok(raw)
3977}
3978
3979#[cfg(all(not(feature = "postgres-sync"), feature = "tokio-postgres"))]
3980async fn query_pg_async_core(
3981 client: &tokio_postgres::Client,
3982 raw: &mut PostgresRawData,
3983) -> Result<(), CliError> {
3984 use drizzle_migrations::postgres::introspect::{RawColumnInfo, RawTableInfo, queries};
3985
3986 raw.schemas = client
3987 .query(queries::SCHEMAS_QUERY, &[])
3988 .await
3989 .map_err(|e| CliError::Other(format!("Failed to query schemas: {e}")))?
3990 .into_iter()
3991 .map(|row| RawSchemaInfo {
3992 name: row.get::<_, String>(0),
3993 })
3994 .collect();
3995
3996 raw.tables = client
3997 .query(queries::TABLES_QUERY, &[])
3998 .await
3999 .map_err(|e| CliError::Other(format!("Failed to query tables: {e}")))?
4000 .into_iter()
4001 .map(|row| RawTableInfo {
4002 schema: row.get::<_, String>(0),
4003 name: row.get::<_, String>(1),
4004 is_rls_enabled: row.get::<_, bool>(2),
4005 })
4006 .collect();
4007
4008 raw.columns = client
4009 .query(queries::COLUMNS_QUERY, &[])
4010 .await
4011 .map_err(|e| CliError::Other(format!("Failed to query columns: {e}")))?
4012 .into_iter()
4013 .map(|row| RawColumnInfo {
4014 schema: row.get::<_, String>(0),
4015 table: row.get::<_, String>(1),
4016 name: row.get::<_, String>(2),
4017 column_type: row.get::<_, String>(3),
4018 type_schema: row.get::<_, Option<String>>(4),
4019 not_null: row.get::<_, bool>(5),
4020 default_value: row.get::<_, Option<String>>(6),
4021 is_identity: row.get::<_, bool>(7),
4022 identity_type: row.get::<_, Option<String>>(8),
4023 is_generated: row.get::<_, bool>(9),
4024 generated_expression: row.get::<_, Option<String>>(10),
4025 generated_stored: row.get::<_, bool>(11),
4026 ordinal_position: row.get::<_, i32>(12),
4027 })
4028 .collect();
4029
4030 Ok(())
4031}
4032
4033#[cfg(all(not(feature = "postgres-sync"), feature = "tokio-postgres"))]
4034async fn query_pg_async_codegen_meta(
4035 client: &tokio_postgres::Client,
4036 raw: &mut PostgresRawData,
4037) -> Result<(), CliError> {
4038 use drizzle_migrations::postgres::introspect::{
4039 RawEnumInfo, RawSequenceInfo, RawViewInfo, queries,
4040 };
4041
4042 raw.enums = client
4043 .query(queries::ENUMS_QUERY, &[])
4044 .await
4045 .map_err(|e| CliError::Other(format!("Failed to query enums: {e}")))?
4046 .into_iter()
4047 .map(|row| RawEnumInfo {
4048 schema: row.get::<_, String>(0),
4049 name: row.get::<_, String>(1),
4050 values: row.get::<_, Vec<String>>(2),
4051 })
4052 .collect();
4053
4054 raw.sequences = client
4055 .query(queries::SEQUENCES_QUERY, &[])
4056 .await
4057 .map_err(|e| CliError::Other(format!("Failed to query sequences: {e}")))?
4058 .into_iter()
4059 .map(|row| RawSequenceInfo {
4060 schema: row.get::<_, String>(0),
4061 name: row.get::<_, String>(1),
4062 data_type: row.get::<_, Option<String>>(2),
4063 start_value: row.get::<_, Option<String>>(3),
4064 min_value: row.get::<_, Option<String>>(4),
4065 max_value: row.get::<_, Option<String>>(5),
4066 increment: row.get::<_, Option<String>>(6),
4067 cycle: row.get::<_, Option<bool>>(7),
4068 cache_value: row.get::<_, Option<String>>(8),
4069 })
4070 .collect();
4071
4072 let view_schema_filters: Option<Vec<String>> = None;
4073 raw.views = client
4074 .query(queries::VIEWS_QUERY, &[&view_schema_filters])
4075 .await
4076 .map_err(|e| CliError::Other(format!("Failed to query views: {e}")))?
4077 .into_iter()
4078 .map(|row| RawViewInfo {
4079 schema: row.get::<_, String>(0),
4080 name: row.get::<_, String>(1),
4081 definition: row.get::<_, String>(2),
4082 is_materialized: row.get::<_, bool>(3),
4083 })
4084 .collect();
4085
4086 Ok(())
4087}
4088
4089#[cfg(all(not(feature = "postgres-sync"), feature = "tokio-postgres"))]
4090async fn query_pg_async_constraints(
4091 client: &tokio_postgres::Client,
4092 raw: &mut PostgresRawData,
4093) -> Result<(), CliError> {
4094 use drizzle_migrations::postgres::introspect::{
4095 RawCheckInfo, RawForeignKeyInfo, RawIndexInfo, RawPrimaryKeyInfo, RawUniqueInfo,
4096 parse_index_columns, pg_action_code_to_string, queries,
4097 };
4098
4099 raw.indexes = client
4100 .query(queries::INDEXES_QUERY, &[])
4101 .await
4102 .map_err(|e| CliError::Other(format!("Failed to query indexes: {e}")))?
4103 .into_iter()
4104 .map(|row| {
4105 let cols: Vec<String> = row.get(6);
4106 RawIndexInfo {
4107 schema: row.get::<_, String>(0),
4108 table: row.get::<_, String>(1),
4109 name: row.get::<_, String>(2),
4110 is_unique: row.get::<_, bool>(3),
4111 is_primary: row.get::<_, bool>(4),
4112 method: row.get::<_, String>(5),
4113 columns: parse_index_columns(cols),
4114 where_clause: row.get::<_, Option<String>>(7),
4115 concurrent: false,
4116 }
4117 })
4118 .collect();
4119
4120 raw.foreign_keys = client
4121 .query(queries::FOREIGN_KEYS_QUERY, &[])
4122 .await
4123 .map_err(|e| CliError::Other(format!("Failed to query foreign keys: {e}")))?
4124 .into_iter()
4125 .map(|row| RawForeignKeyInfo {
4126 schema: row.get::<_, String>(0),
4127 table: row.get::<_, String>(1),
4128 name: row.get::<_, String>(2),
4129 columns: row.get::<_, Vec<String>>(3),
4130 schema_to: row.get::<_, String>(4),
4131 table_to: row.get::<_, String>(5),
4132 columns_to: row.get::<_, Vec<String>>(6),
4133 on_update: pg_action_code_to_string(&row.get::<_, String>(7)),
4134 on_delete: pg_action_code_to_string(&row.get::<_, String>(8)),
4135 })
4136 .collect();
4137
4138 raw.primary_keys = client
4139 .query(queries::PRIMARY_KEYS_QUERY, &[])
4140 .await
4141 .map_err(|e| CliError::Other(format!("Failed to query primary keys: {e}")))?
4142 .into_iter()
4143 .map(|row| RawPrimaryKeyInfo {
4144 schema: row.get::<_, String>(0),
4145 table: row.get::<_, String>(1),
4146 name: row.get::<_, String>(2),
4147 columns: row.get::<_, Vec<String>>(3),
4148 })
4149 .collect();
4150
4151 raw.uniques = client
4152 .query(queries::UNIQUES_QUERY, &[])
4153 .await
4154 .map_err(|e| CliError::Other(format!("Failed to query unique constraints: {e}")))?
4155 .into_iter()
4156 .map(|row| RawUniqueInfo {
4157 schema: row.get::<_, String>(0),
4158 table: row.get::<_, String>(1),
4159 name: row.get::<_, String>(2),
4160 columns: row.get::<_, Vec<String>>(3),
4161 nulls_not_distinct: row.get::<_, bool>(4),
4162 })
4163 .collect();
4164
4165 raw.checks = client
4166 .query(queries::CHECKS_QUERY, &[])
4167 .await
4168 .map_err(|e| CliError::Other(format!("Failed to query check constraints: {e}")))?
4169 .into_iter()
4170 .map(|row| RawCheckInfo {
4171 schema: row.get::<_, String>(0),
4172 table: row.get::<_, String>(1),
4173 name: row.get::<_, String>(2),
4174 expression: row.get::<_, String>(3),
4175 })
4176 .collect();
4177
4178 Ok(())
4179}
4180
4181#[cfg(all(not(feature = "postgres-sync"), feature = "tokio-postgres"))]
4182async fn query_pg_async_security(
4183 client: &tokio_postgres::Client,
4184 raw: &mut PostgresRawData,
4185) -> Result<(), CliError> {
4186 use drizzle_migrations::postgres::introspect::{RawPolicyInfo, RawRoleInfo, queries};
4187
4188 raw.roles = client
4189 .query(queries::ROLES_QUERY, &[])
4190 .await
4191 .map_err(|e| CliError::Other(format!("Failed to query roles: {e}")))?
4192 .into_iter()
4193 .map(|row| RawRoleInfo {
4194 name: row.get::<_, String>(0),
4195 create_db: row.get::<_, bool>(1),
4196 create_role: row.get::<_, bool>(2),
4197 inherit: row.get::<_, bool>(3),
4198 })
4199 .collect();
4200
4201 raw.policies = client
4202 .query(queries::POLICIES_QUERY, &[])
4203 .await
4204 .map_err(|e| CliError::Other(format!("Failed to query policies: {e}")))?
4205 .into_iter()
4206 .map(|row| RawPolicyInfo {
4207 schema: row.get::<_, String>(0),
4208 table: row.get::<_, String>(1),
4209 name: row.get::<_, String>(2),
4210 as_clause: row.get::<_, String>(3),
4211 for_clause: row.get::<_, String>(4),
4212 to: row.get::<_, Vec<String>>(5),
4213 using: row.get::<_, Option<String>>(6),
4214 with_check: row.get::<_, Option<String>>(7),
4215 })
4216 .collect();
4217
4218 Ok(())
4219}
4220
4221#[cfg(any(feature = "postgres-sync", feature = "tokio-postgres"))]
4223#[derive(Debug, Clone)]
4224struct RawSchemaInfo {
4225 name: String,
4226}
4227
4228#[cfg(any(feature = "postgres-sync", feature = "tokio-postgres"))]
4234struct PostgresRawData {
4235 schemas: Vec<RawSchemaInfo>,
4236 tables: Vec<drizzle_migrations::postgres::introspect::RawTableInfo>,
4237 columns: Vec<drizzle_migrations::postgres::introspect::RawColumnInfo>,
4238 enums: Vec<drizzle_migrations::postgres::introspect::RawEnumInfo>,
4239 sequences: Vec<drizzle_migrations::postgres::introspect::RawSequenceInfo>,
4240 views: Vec<drizzle_migrations::postgres::introspect::RawViewInfo>,
4241 indexes: Vec<drizzle_migrations::postgres::introspect::RawIndexInfo>,
4242 foreign_keys: Vec<drizzle_migrations::postgres::introspect::RawForeignKeyInfo>,
4243 primary_keys: Vec<drizzle_migrations::postgres::introspect::RawPrimaryKeyInfo>,
4244 uniques: Vec<drizzle_migrations::postgres::introspect::RawUniqueInfo>,
4245 checks: Vec<drizzle_migrations::postgres::introspect::RawCheckInfo>,
4246 roles: Vec<drizzle_migrations::postgres::introspect::RawRoleInfo>,
4247 policies: Vec<drizzle_migrations::postgres::introspect::RawPolicyInfo>,
4248}
4249
4250#[cfg(any(feature = "postgres-sync", feature = "tokio-postgres"))]
4254fn build_postgres_ddl(
4255 raw: PostgresRawData,
4256) -> drizzle_migrations::postgres::collection::PostgresDDL {
4257 use drizzle_migrations::postgres::{
4258 PostgresDDL,
4259 ddl::Schema,
4260 introspect::{
4261 process_check_constraints, process_columns, process_enums, process_foreign_keys,
4262 process_indexes, process_policies, process_primary_keys, process_roles,
4263 process_sequences, process_tables, process_unique_constraints, process_views,
4264 },
4265 };
4266
4267 let mut ddl = PostgresDDL::new();
4268 for s in raw.schemas.into_iter().map(|s| Schema::new(s.name)) {
4269 ddl.schemas.push(s);
4270 }
4271 for e in process_enums(&raw.enums) {
4272 ddl.enums.push(e);
4273 }
4274 for s in process_sequences(&raw.sequences) {
4275 ddl.sequences.push(s);
4276 }
4277 for r in process_roles(&raw.roles) {
4278 ddl.roles.push(r);
4279 }
4280 for p in process_policies(&raw.policies) {
4281 ddl.policies.push(p);
4282 }
4283 for t in process_tables(&raw.tables) {
4284 ddl.tables.push(t);
4285 }
4286 for c in process_columns(&raw.columns) {
4287 ddl.columns.push(c);
4288 }
4289 for i in process_indexes(&raw.indexes) {
4290 ddl.indexes.push(i);
4291 }
4292 for fk in process_foreign_keys(&raw.foreign_keys) {
4293 ddl.fks.push(fk);
4294 }
4295 for pk in process_primary_keys(&raw.primary_keys) {
4296 ddl.pks.push(pk);
4297 }
4298 for u in process_unique_constraints(&raw.uniques) {
4299 ddl.uniques.push(u);
4300 }
4301 for c in process_check_constraints(&raw.checks) {
4302 ddl.checks.push(c);
4303 }
4304 for v in process_views(&raw.views) {
4305 ddl.views.push(v);
4306 }
4307 ddl
4308}
4309
4310#[cfg(any(feature = "postgres-sync", feature = "tokio-postgres"))]
4312fn finalize_postgres_introspection(
4313 ddl: &drizzle_migrations::postgres::collection::PostgresDDL,
4314 url: &str,
4315) -> IntrospectResult {
4316 use drizzle_migrations::postgres::codegen::{
4317 CodegenOptions, FieldCasing, generate_rust_schema,
4318 };
4319
4320 let options = CodegenOptions {
4321 module_doc: Some(format!("Schema introspected from {}", mask_url(url))),
4322 include_schema: true,
4323 schema_name: "Schema".to_string(),
4324 use_pub: true,
4325 field_casing: FieldCasing::default(),
4326 };
4327 let generated = generate_rust_schema(ddl, &options);
4328
4329 let mut snap = drizzle_migrations::postgres::PostgresSnapshot::new();
4330 for entity in ddl.to_entities() {
4331 snap.add_entity(entity);
4332 }
4333
4334 IntrospectResult {
4335 schema_code: generated.code,
4336 table_count: ddl.tables.list().len(),
4337 index_count: ddl.indexes.list().len(),
4338 view_count: ddl.views.list().len(),
4339 warnings: generated.warnings,
4340 snapshot: Snapshot::Postgres(snap),
4341 snapshot_path: std::path::PathBuf::new(),
4342 }
4343}
4344
4345#[cfg(any(feature = "postgres-sync", feature = "tokio-postgres"))]
4346fn mask_url(url: &str) -> String {
4347 if let Some(at) = url.find('@')
4348 && let Some(colon) = url[..at].rfind(':')
4349 {
4350 let scheme_end = url.find("://").map_or(0, |p| p + 3);
4351 if colon > scheme_end {
4352 return format!("{}****{}", &url[..=colon], &url[at..]);
4353 }
4354 }
4355 url.to_string()
4356}
4357
4358#[cfg(test)]
4359mod tests {
4360 use super::*;
4361
4362 #[cfg(any(feature = "postgres-sync", feature = "tokio-postgres"))]
4363 fn test_postgres_url() -> String {
4364 std::env::var("DATABASE_URL")
4365 .unwrap_or_else(|_| "postgres://postgres:postgres@localhost:5432/drizzle_test".into())
4366 }
4367
4368 #[cfg(any(feature = "postgres-sync", feature = "tokio-postgres"))]
4369 fn test_postgres_creds() -> crate::config::PostgresCreds {
4370 crate::config::PostgresCreds::Url(test_postgres_url().into_boxed_str())
4371 }
4372
4373 #[cfg(any(feature = "postgres-sync", feature = "tokio-postgres"))]
4374 fn unique_pg_name(prefix: &str) -> String {
4375 use std::time::{SystemTime, UNIX_EPOCH};
4376
4377 let nanos = SystemTime::now()
4378 .duration_since(UNIX_EPOCH)
4379 .expect("time went backwards")
4380 .as_nanos();
4381 format!("{}_{}_{}", prefix, std::process::id(), nanos)
4382 }
4383
4384 #[test]
4385 fn destructive_statement_detection_covers_drop_variants() {
4386 assert!(is_destructive_statement("DROP TABLE users;"));
4387 assert!(is_destructive_statement("DROP VIEW active_users;"));
4388 assert!(is_destructive_statement("DROP TYPE status;"));
4389 assert!(is_destructive_statement("DROP SCHEMA auth;"));
4390 assert!(is_destructive_statement("DROP ROLE app_user;"));
4391 assert!(is_destructive_statement(
4392 "DROP POLICY users_rls_policy ON users;"
4393 ));
4394 assert!(is_destructive_statement("TRUNCATE users;"));
4395 assert!(is_destructive_statement(
4396 "ALTER TABLE users DROP CONSTRAINT users_email_key;"
4397 ));
4398
4399 assert!(!is_destructive_statement("CREATE TABLE users(id INTEGER);"));
4400 assert!(!is_destructive_statement(
4401 "ALTER TABLE users ADD COLUMN email text;"
4402 ));
4403 }
4404
4405 #[cfg(feature = "rusqlite")]
4406 #[test]
4407 fn sqlite_migrations_run_both_when_created_at_collides() {
4408 use drizzle_migrations::{Migration, Migrations};
4412
4413 let dir = tempfile::tempdir().expect("tempdir");
4414 let db_path = dir.path().join("migrate.sqlite");
4415 let db_path_str = db_path.to_string_lossy().to_string();
4416
4417 let first_set = Migrations::new(
4418 vec![Migration::with_hash(
4419 "20230331141203_first",
4420 "hash_one",
4421 1_680_271_923_000,
4422 vec!["CREATE TABLE created_at_dedupe_a (id INTEGER PRIMARY KEY)".to_string()],
4423 )],
4424 drizzle_types::Dialect::SQLite,
4425 );
4426
4427 let first =
4428 run_sqlite_migrations(&first_set, &db_path_str).expect("first migrate succeeds");
4429 assert_eq!(first.applied_count, 1);
4430
4431 let second_set = Migrations::new(
4432 vec![
4433 Migration::with_hash(
4434 "20230331141203_first",
4435 "hash_one",
4436 1_680_271_923_000,
4437 vec!["CREATE TABLE created_at_dedupe_a (id INTEGER PRIMARY KEY)".to_string()],
4438 ),
4439 Migration::with_hash(
4440 "20230331141203_second",
4441 "hash_two",
4442 1_680_271_923_000,
4443 vec!["CREATE TABLE created_at_dedupe_b (id INTEGER PRIMARY KEY)".to_string()],
4444 ),
4445 ],
4446 drizzle_types::Dialect::SQLite,
4447 );
4448
4449 let second =
4450 run_sqlite_migrations(&second_set, &db_path_str).expect("second migrate succeeds");
4451 assert_eq!(
4452 second.applied_count, 1,
4453 "only the second (newly introduced by name) migration should apply"
4454 );
4455
4456 let conn = rusqlite::Connection::open(&db_path).expect("open sqlite");
4457 let rows: i64 = conn
4458 .query_row("SELECT COUNT(*) FROM __drizzle_migrations", [], |row| {
4459 row.get(0)
4460 })
4461 .expect("count migrations rows");
4462 assert_eq!(rows, 2, "both migration records should be stored");
4463
4464 let table_b_exists: i64 = conn
4465 .query_row(
4466 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='created_at_dedupe_b'",
4467 [],
4468 |row| row.get(0),
4469 )
4470 .expect("query sqlite_master");
4471 assert_eq!(
4472 table_b_exists, 1,
4473 "the new-name migration must execute even though created_at collides"
4474 );
4475 }
4476
4477 #[cfg(feature = "rusqlite")]
4478 #[test]
4479 fn run_migrations_creates_metadata_table_with_no_local_migrations() {
4480 let dir = tempfile::tempdir().expect("tempdir");
4481 let db_path = dir.path().join("empty.sqlite");
4482 let migrations_dir = dir.path().join("migrations");
4483 std::fs::create_dir_all(&migrations_dir).expect("create migrations dir");
4484
4485 let creds = crate::config::Credentials::Sqlite {
4486 path: db_path.to_string_lossy().to_string().into_boxed_str(),
4487 };
4488
4489 let result = run_migrations(
4490 &creds,
4491 crate::config::Dialect::Sqlite,
4492 &migrations_dir,
4493 "__drizzle_migrations",
4494 "drizzle",
4495 )
4496 .expect("run migrations");
4497 assert_eq!(result.applied_count, 0);
4498
4499 let conn = rusqlite::Connection::open(&db_path).expect("open sqlite");
4500 let exists: i64 = conn
4501 .query_row(
4502 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='__drizzle_migrations'",
4503 [],
4504 |row| row.get(0),
4505 )
4506 .expect("check metadata table");
4507 assert_eq!(exists, 1, "migrations metadata table should be created");
4508 }
4509
4510 #[cfg(any(
4511 feature = "rusqlite",
4512 feature = "libsql",
4513 feature = "turso",
4514 feature = "postgres-sync",
4515 feature = "tokio-postgres"
4516 ))]
4517 #[test]
4518 fn validate_init_metadata_matches_drizzle_orm_semantics() {
4519 use drizzle_migrations::{Migration, Migrations};
4520
4521 let empty_set = Migrations::empty(drizzle_types::Dialect::SQLite);
4522 validate_init_metadata(&[], &empty_set).expect("empty local migrations should be allowed");
4523
4524 let single = Migrations::new(
4525 vec![Migration::with_hash(
4526 "20230331141203_init",
4527 "hash_single",
4528 1_680_271_923_000,
4529 vec!["CREATE TABLE t(id INTEGER PRIMARY KEY)".to_string()],
4530 )],
4531 drizzle_types::Dialect::SQLite,
4532 );
4533 validate_init_metadata(&[], &single).expect("single local migration should be allowed");
4534
4535 let multiple = Migrations::new(
4536 vec![
4537 Migration::with_hash(
4538 "20230331141203_first",
4539 "hash_a",
4540 1_680_271_923_000,
4541 vec!["CREATE TABLE a(id INTEGER PRIMARY KEY)".to_string()],
4542 ),
4543 Migration::with_hash(
4544 "20230331150000_second",
4545 "hash_b",
4546 1_680_275_400_000,
4547 vec!["CREATE TABLE b(id INTEGER PRIMARY KEY)".to_string()],
4548 ),
4549 ],
4550 drizzle_types::Dialect::SQLite,
4551 );
4552
4553 let err =
4554 validate_init_metadata(&[], &multiple).expect_err("multiple local migrations rejected");
4555 assert_eq!(
4556 err.to_string(),
4557 "--init can't be used with existing migrations"
4558 );
4559
4560 let err = validate_init_metadata(&["20230331141203_init".to_string()], &single)
4561 .expect_err("existing db metadata should be rejected");
4562 assert_eq!(
4563 err.to_string(),
4564 "--init can't be used when database already has migrations set"
4565 );
4566 }
4567
4568 #[test]
4569 fn verify_applied_migrations_detects_hash_mismatch() {
4570 use drizzle_migrations::{Migration, Migrations};
4571
4572 let set = Migrations::new(
4573 vec![Migration::with_hash(
4574 "20230331141203_verify",
4575 "local_hash",
4576 1_680_271_923_000,
4577 vec!["CREATE TABLE t(id INTEGER PRIMARY KEY)".to_string()],
4578 )],
4579 drizzle_types::Dialect::SQLite,
4580 );
4581
4582 let applied = vec![AppliedMigrationRecord {
4583 hash: "db_hash".to_string(),
4584 name: "20230331141203_verify".to_string(),
4585 }];
4586
4587 let err = verify_applied_migrations_consistency(&set, &applied)
4588 .expect_err("hash mismatch should fail verification");
4589 assert_eq!(
4590 err.to_string(),
4591 "Migration failed: Migration hash mismatch for 20230331141203_verify: database=db_hash, local=local_hash"
4592 );
4593 }
4594
4595 #[test]
4596 fn build_migration_plan_counts_pending_statements() {
4597 use drizzle_migrations::{Migration, Migrations};
4598
4599 let set = Migrations::new(
4600 vec![
4601 Migration::with_hash(
4602 "20230331141203_first",
4603 "hash_a",
4604 1_680_271_923_000,
4605 vec![
4606 "CREATE TABLE a(id INTEGER PRIMARY KEY)".to_string(),
4607 "CREATE INDEX a_id_idx ON a(id)".to_string(),
4608 ],
4609 ),
4610 Migration::with_hash(
4611 "20230331150000_second",
4612 "hash_b",
4613 1_680_275_400_000,
4614 vec!["CREATE TABLE b(id INTEGER PRIMARY KEY)".to_string()],
4615 ),
4616 ],
4617 drizzle_types::Dialect::SQLite,
4618 );
4619
4620 let applied = vec![AppliedMigrationRecord {
4621 hash: "hash_a".to_string(),
4622 name: "20230331141203_first".to_string(),
4623 }];
4624
4625 let plan = build_migration_plan(&set, &applied).expect("build migration plan");
4626 assert_eq!(plan.applied_count, 1);
4627 assert_eq!(plan.pending_count, 1);
4628 assert_eq!(plan.pending_statements, 1);
4629 assert_eq!(plan.pending_migrations, vec!["20230331150000_second"]);
4630 }
4631
4632 #[cfg(any(feature = "postgres-sync", feature = "tokio-postgres"))]
4633 #[test]
4634 fn postgres_concurrent_index_detection_is_case_insensitive() {
4635 assert!(is_postgres_concurrent_index_statement(
4636 "CREATE INDEX CONCURRENTLY users_email_idx ON users (email);"
4637 ));
4638 assert!(is_postgres_concurrent_index_statement(
4639 "CREATE UNIQUE INDEX CONCURRENTLY users_email_idx ON users (email);"
4640 ));
4641 assert!(is_postgres_concurrent_index_statement(
4642 "drop index concurrently users_email_idx;"
4643 ));
4644 assert!(!is_postgres_concurrent_index_statement(
4645 "CREATE INDEX users_email_idx ON users (email);"
4646 ));
4647 }
4648
4649 #[cfg(any(feature = "postgres-sync", feature = "tokio-postgres"))]
4650 #[test]
4651 fn postgres_concurrent_index_detection_over_statement_list() {
4652 let with_concurrent = vec![
4653 "CREATE TABLE users(id integer);".to_string(),
4654 "CREATE INDEX CONCURRENTLY users_email_idx ON users (id);".to_string(),
4655 ];
4656 let without_concurrent = vec![
4657 "CREATE TABLE users(id integer);".to_string(),
4658 "CREATE INDEX users_email_idx ON users (id);".to_string(),
4659 ];
4660
4661 assert!(has_postgres_concurrent_index(&with_concurrent));
4662 assert!(!has_postgres_concurrent_index(&without_concurrent));
4663 }
4664
4665 #[test]
4666 fn generate_push_sql_includes_concurrent_postgres_index() {
4667 use crate::snapshot::parse_result_to_snapshot;
4668 use drizzle_migrations::parser::SchemaParser;
4669 use drizzle_types::Dialect;
4670
4671 let previous = r#"
4672#[PostgresTable]
4673pub struct Users {
4674 #[column(primary)]
4675 pub id: i32,
4676 pub email: String,
4677}
4678"#;
4679
4680 let current = r#"
4681#[PostgresTable]
4682pub struct Users {
4683 #[column(primary)]
4684 pub id: i32,
4685 pub email: String,
4686}
4687
4688#[PostgresIndex(concurrent)]
4689pub struct UsersEmailIdx(Users::email);
4690"#;
4691
4692 let prev =
4693 parse_result_to_snapshot(&SchemaParser::parse(previous), Dialect::PostgreSQL, None);
4694 let curr =
4695 parse_result_to_snapshot(&SchemaParser::parse(current), Dialect::PostgreSQL, None);
4696
4697 let (sql, warnings) = generate_push_sql(&prev, &curr, false).expect("push sql generation");
4698 assert!(warnings.is_empty());
4699 assert_eq!(sql.len(), 1);
4700 assert_eq!(
4701 sql[0],
4702 "CREATE INDEX CONCURRENTLY \"users_email_idx\" ON \"users\" USING btree (\"email\" NULLS LAST);"
4703 );
4704 }
4705
4706 #[test]
4707 fn filter_patterns_support_negation_globs() {
4708 let raw = vec![
4709 "users_*".to_string(),
4710 "!users_4".to_string(),
4711 "!ad*".to_string(),
4712 ];
4713 let patterns = compile_patterns(Some(&raw)).expect("compile patterns");
4714
4715 assert!(matches_patterns("users_1", patterns.as_deref()));
4716 assert!(!matches_patterns("users_4", patterns.as_deref()));
4717 assert!(!matches_patterns("admin", patterns.as_deref()));
4718 assert!(!matches_patterns("audit", patterns.as_deref()));
4719 }
4720
4721 #[test]
4722 fn postgres_table_filter_matches_table_name_only() {
4723 use crate::snapshot::parse_result_to_snapshot;
4724 use drizzle_migrations::parser::SchemaParser;
4725 use drizzle_migrations::schema::Snapshot;
4726 use drizzle_types::Dialect as BaseDialect;
4727
4728 let code = r#"
4729#[PostgresTable(schema = "admin")]
4730pub struct AuditLog {
4731 #[column(primary)]
4732 pub id: i32,
4733}
4734
4735#[PostgresTable(schema = "public")]
4736pub struct Users {
4737 #[column(primary)]
4738 pub id: i32,
4739}
4740"#;
4741
4742 let parsed = SchemaParser::parse(code);
4743 let mut snapshot = parse_result_to_snapshot(&parsed, BaseDialect::PostgreSQL, None);
4744 let filters = SnapshotFilters {
4745 tables: Some(vec!["admin.*".to_string()]),
4746 schemas: None,
4747 extensions: None,
4748 };
4749
4750 apply_snapshot_filters(&mut snapshot, crate::config::Dialect::Postgresql, &filters)
4751 .expect("apply filters");
4752
4753 let remaining_tables = match snapshot {
4754 Snapshot::Postgres(s) => s
4755 .ddl
4756 .iter()
4757 .filter_map(|e| {
4758 if let drizzle_types::postgres::ddl::PostgresEntity::Table(t) = e {
4759 Some((t.schema.to_string(), t.name.to_string()))
4760 } else {
4761 None
4762 }
4763 })
4764 .collect::<Vec<_>>(),
4765 _ => panic!("expected postgres snapshot"),
4766 };
4767
4768 assert!(remaining_tables.is_empty());
4769 }
4770
4771 #[test]
4772 fn postgres_schema_and_table_filters_intersect() {
4773 use crate::snapshot::parse_result_to_snapshot;
4774 use drizzle_migrations::parser::SchemaParser;
4775 use drizzle_migrations::schema::Snapshot;
4776 use drizzle_types::Dialect as BaseDialect;
4777
4778 let code = r#"
4779#[PostgresTable(schema = "dev")]
4780pub struct UsersDev {
4781 #[column(primary)]
4782 pub id: i32,
4783}
4784
4785#[PostgresTable(schema = "public")]
4786pub struct UsersPublic {
4787 #[column(primary)]
4788 pub id: i32,
4789}
4790"#;
4791
4792 let parsed = SchemaParser::parse(code);
4793 let mut snapshot = parse_result_to_snapshot(&parsed, BaseDialect::PostgreSQL, None);
4794 let filters = SnapshotFilters {
4795 tables: Some(vec!["users_*".to_string()]),
4796 schemas: Some(vec!["!dev".to_string()]),
4797 extensions: None,
4798 };
4799
4800 apply_snapshot_filters(&mut snapshot, crate::config::Dialect::Postgresql, &filters)
4801 .expect("apply filters");
4802
4803 let remaining_tables = match snapshot {
4804 Snapshot::Postgres(s) => s
4805 .ddl
4806 .iter()
4807 .filter_map(|e| {
4808 if let drizzle_types::postgres::ddl::PostgresEntity::Table(t) = e {
4809 Some((t.schema.to_string(), t.name.to_string()))
4810 } else {
4811 None
4812 }
4813 })
4814 .collect::<Vec<_>>(),
4815 _ => panic!("expected postgres snapshot"),
4816 };
4817
4818 assert_eq!(
4819 remaining_tables,
4820 vec![("public".to_string(), "users_public".to_string())]
4821 );
4822 }
4823
4824 #[test]
4825 fn postgres_extensions_filter_excludes_postgis_internal_objects() {
4826 use crate::snapshot::parse_result_to_snapshot;
4827 use drizzle_migrations::parser::SchemaParser;
4828 use drizzle_migrations::schema::Snapshot;
4829 use drizzle_types::Dialect as BaseDialect;
4830
4831 let code = r#"
4832#[PostgresTable(schema = "topology")]
4833pub struct TopologyLayer {
4834 #[column(primary)]
4835 pub id: i32,
4836}
4837
4838#[PostgresTable]
4839pub struct SpatialRefSys {
4840 #[column(primary)]
4841 pub id: i32,
4842}
4843
4844#[PostgresTable]
4845pub struct Users {
4846 #[column(primary)]
4847 pub id: i32,
4848}
4849"#;
4850
4851 let parsed = SchemaParser::parse(code);
4852 let mut snapshot = parse_result_to_snapshot(&parsed, BaseDialect::PostgreSQL, None);
4853 let filters = SnapshotFilters {
4854 tables: None,
4855 schemas: None,
4856 extensions: Some(vec![Extension::Postgis]),
4857 };
4858
4859 apply_snapshot_filters(&mut snapshot, crate::config::Dialect::Postgresql, &filters)
4860 .expect("apply filters");
4861
4862 let remaining_tables = match snapshot {
4863 Snapshot::Postgres(s) => s
4864 .ddl
4865 .iter()
4866 .filter_map(|e| {
4867 if let drizzle_types::postgres::ddl::PostgresEntity::Table(t) = e {
4868 Some((t.schema.to_string(), t.name.to_string()))
4869 } else {
4870 None
4871 }
4872 })
4873 .collect::<Vec<_>>(),
4874 _ => panic!("expected postgres snapshot"),
4875 };
4876
4877 assert_eq!(
4878 remaining_tables,
4879 vec![("public".to_string(), "users".to_string())]
4880 );
4881 }
4882
4883 #[test]
4884 fn format_migration_sql_respects_breakpoints_flag() {
4885 let sql = vec![
4886 "CREATE TABLE users(id integer);".to_string(),
4887 "CREATE INDEX users_id_idx ON users(id);".to_string(),
4888 ];
4889
4890 let with_breakpoints = format_migration_sql(&sql, true);
4891 assert_eq!(
4892 with_breakpoints,
4893 "CREATE TABLE users(id integer);\n--> statement-breakpoint\nCREATE INDEX users_id_idx ON users(id);"
4894 );
4895
4896 let without_breakpoints = format_migration_sql(&sql, false);
4897 assert_eq!(
4898 without_breakpoints,
4899 "CREATE TABLE users(id integer);\n\nCREATE INDEX users_id_idx ON users(id);"
4900 );
4901
4902 let empty = format_migration_sql(&[], false);
4903 assert_eq!(empty, "-- No tables to create (empty database)\n");
4904 }
4905
4906 #[test]
4907 fn regenerate_sqlite_schema_applies_introspect_casing() {
4908 use crate::config::{Casing, IntrospectCasing};
4909 use crate::snapshot::parse_result_to_snapshot;
4910 use drizzle_migrations::parser::SchemaParser;
4911 use drizzle_types::Dialect as BaseDialect;
4912
4913 let code = r#"
4914#[SQLiteTable]
4915pub struct AuditLogs {
4916 #[column(primary)]
4917 pub id: i64,
4918 pub user_name: String,
4919}
4920"#;
4921
4922 let parsed = SchemaParser::parse(code);
4923 let snapshot =
4924 parse_result_to_snapshot(&parsed, BaseDialect::SQLite, Some(Casing::SnakeCase));
4925
4926 let mut camel = IntrospectResult {
4927 schema_code: String::new(),
4928 table_count: 0,
4929 index_count: 0,
4930 view_count: 0,
4931 warnings: Vec::new(),
4932 snapshot: snapshot.clone(),
4933 snapshot_path: std::path::PathBuf::new(),
4934 };
4935 regenerate_schema_from_snapshot(
4936 &mut camel,
4937 crate::config::Dialect::Sqlite,
4938 Some(IntrospectCasing::Camel),
4939 );
4940
4941 assert_eq!(
4942 camel.schema_code,
4943 "\
4944//! Auto-generated SQLite schema from introspection
4945//!
4946//! Schema introspected from filtered database objects
4947
4948use drizzle::sqlite::prelude::*;
4949
4950#[SQLiteTable(name = \"audit_logs\")]
4951pub struct AuditLogs {
4952 #[column(primary)]
4953 pub id: i64,
4954 pub userName: String,
4955}
4956
4957#[derive(SQLiteSchema)]
4958pub struct Schema {
4959 pub auditLogs: AuditLogs,
4960}
4961"
4962 );
4963
4964 let mut preserve = IntrospectResult {
4965 schema_code: String::new(),
4966 table_count: 0,
4967 index_count: 0,
4968 view_count: 0,
4969 warnings: Vec::new(),
4970 snapshot,
4971 snapshot_path: std::path::PathBuf::new(),
4972 };
4973 regenerate_schema_from_snapshot(
4974 &mut preserve,
4975 crate::config::Dialect::Sqlite,
4976 Some(IntrospectCasing::Preserve),
4977 );
4978
4979 assert_eq!(
4980 preserve.schema_code,
4981 "\
4982//! Auto-generated SQLite schema from introspection
4983//!
4984//! Schema introspected from filtered database objects
4985
4986use drizzle::sqlite::prelude::*;
4987
4988#[SQLiteTable(name = \"audit_logs\")]
4989pub struct AuditLogs {
4990 #[column(primary)]
4991 pub id: i64,
4992 pub user_name: String,
4993}
4994
4995#[derive(SQLiteSchema)]
4996pub struct Schema {
4997 pub audit_logs: AuditLogs,
4998}
4999"
5000 );
5001 }
5002
5003 #[test]
5004 fn regenerate_postgres_schema_applies_introspect_casing() {
5005 use crate::config::{Casing, IntrospectCasing};
5006 use crate::snapshot::parse_result_to_snapshot;
5007 use drizzle_migrations::parser::SchemaParser;
5008 use drizzle_types::Dialect as BaseDialect;
5009
5010 let code = r#"
5011#[PostgresTable]
5012pub struct AuditLogs {
5013 #[column(primary)]
5014 pub id: i32,
5015 pub user_name: String,
5016}
5017"#;
5018
5019 let parsed = SchemaParser::parse(code);
5020 let snapshot =
5021 parse_result_to_snapshot(&parsed, BaseDialect::PostgreSQL, Some(Casing::SnakeCase));
5022
5023 let mut camel = IntrospectResult {
5024 schema_code: String::new(),
5025 table_count: 0,
5026 index_count: 0,
5027 view_count: 0,
5028 warnings: Vec::new(),
5029 snapshot: snapshot.clone(),
5030 snapshot_path: std::path::PathBuf::new(),
5031 };
5032 regenerate_schema_from_snapshot(
5033 &mut camel,
5034 crate::config::Dialect::Postgresql,
5035 Some(IntrospectCasing::Camel),
5036 );
5037
5038 assert_eq!(
5039 camel.schema_code,
5040 "\
5041//! Auto-generated PostgreSQL schema from introspection
5042//!
5043//! Schema introspected from filtered database objects
5044
5045use drizzle::postgres::prelude::*;
5046
5047#[PostgresTable]
5048pub struct AuditLogs {
5049 #[column(primary)]
5050 pub id: i32,
5051 pub userName: String,
5052}
5053
5054#[derive(PostgresSchema)]
5055pub struct Schema {
5056 pub auditLogs: AuditLogs,
5057}
5058"
5059 );
5060
5061 let mut preserve = IntrospectResult {
5062 schema_code: String::new(),
5063 table_count: 0,
5064 index_count: 0,
5065 view_count: 0,
5066 warnings: Vec::new(),
5067 snapshot,
5068 snapshot_path: std::path::PathBuf::new(),
5069 };
5070 regenerate_schema_from_snapshot(
5071 &mut preserve,
5072 crate::config::Dialect::Postgresql,
5073 Some(IntrospectCasing::Preserve),
5074 );
5075
5076 assert_eq!(
5077 preserve.schema_code,
5078 "\
5079//! Auto-generated PostgreSQL schema from introspection
5080//!
5081//! Schema introspected from filtered database objects
5082
5083use drizzle::postgres::prelude::*;
5084
5085#[PostgresTable]
5086pub struct AuditLogs {
5087 #[column(primary)]
5088 pub id: i32,
5089 pub user_name: String,
5090}
5091
5092#[derive(PostgresSchema)]
5093pub struct Schema {
5094 pub audit_logs: AuditLogs,
5095}
5096"
5097 );
5098 }
5099
5100 #[cfg(feature = "postgres-sync")]
5101 #[test]
5102 fn postgres_sync_migrate_applies_concurrent_index_without_transaction() {
5103 use drizzle_migrations::{Migration, Migrations};
5104 use drizzle_types::Dialect;
5105
5106 let creds = test_postgres_creds();
5107 let url = creds.connection_url();
5108
5109 let mut setup_client = match postgres::Client::connect(&url, postgres::NoTls) {
5110 Ok(c) => c,
5111 Err(e) => {
5112 eprintln!(
5113 "Skipping postgres_sync_migrate_applies_concurrent_index_without_transaction: {}",
5114 e
5115 );
5116 return;
5117 }
5118 };
5119
5120 let table = unique_pg_name("cli_sync_users");
5121 let index = format!("{}_email_idx", table);
5122 let migration_schema = unique_pg_name("cli_sync_mig");
5123
5124 setup_client
5125 .batch_execute(&format!(
5126 "DROP TABLE IF EXISTS \"{table}\" CASCADE; \
5127 CREATE TABLE \"{table}\" (id integer, email text NOT NULL); \
5128 INSERT INTO \"{table}\" (id, email) VALUES (1, 'a@example.com');"
5129 ))
5130 .expect("setup table for concurrent index test");
5131 drop(setup_client);
5132
5133 let migration_tag = format!("20260212000000_{table}");
5134 let migration_sql =
5135 format!("CREATE INDEX CONCURRENTLY \"{index}\" ON \"{table}\" (\"email\");");
5136 let set = Migrations::with_tracking(
5137 vec![Migration::new(&migration_tag, &migration_sql)],
5138 Dialect::PostgreSQL,
5139 drizzle_migrations::Tracking::POSTGRES.schema(migration_schema.clone()),
5140 );
5141
5142 let result = run_postgres_sync_migrations(&set, &creds)
5143 .expect("sync migration with concurrent index should succeed");
5144 assert_eq!(result.applied_count, 1);
5145
5146 let mut verify_client =
5147 postgres::Client::connect(&url, postgres::NoTls).expect("reconnect for verification");
5148 let exists: i64 = verify_client
5149 .query_one(
5150 "SELECT COUNT(*)::bigint FROM pg_indexes \
5151 WHERE schemaname = 'public' AND tablename = $1 AND indexname = $2",
5152 &[&table, &index],
5153 )
5154 .expect("query pg_indexes")
5155 .get(0);
5156 assert_eq!(exists, 1, "concurrent index was not created");
5157
5158 let _ = verify_client.batch_execute(&format!(
5159 "DROP TABLE IF EXISTS \"{table}\" CASCADE; \
5160 DROP SCHEMA IF EXISTS \"{migration_schema}\" CASCADE;"
5161 ));
5162 }
5163
5164 #[cfg(feature = "postgres-sync")]
5165 #[test]
5166 fn postgres_sync_migrate_upgrades_legacy_tracking_table_and_applies_pending() {
5167 use drizzle_migrations::{Migration, Migrations};
5168 use drizzle_types::Dialect;
5169
5170 let creds = test_postgres_creds();
5171 let url = creds.connection_url();
5172 let mut client = postgres::Client::connect(&url, postgres::NoTls)
5173 .expect("connect postgres for legacy tracking upgrade test");
5174
5175 let applied_table = unique_pg_name("cli_sync_applied");
5176 let pending_table = unique_pg_name("cli_sync_pending");
5177 let migration_schema = unique_pg_name("cli_sync_tracking");
5178
5179 client
5180 .batch_execute(&format!(
5181 "DROP TABLE IF EXISTS \"{applied_table}\" CASCADE; \
5182 DROP TABLE IF EXISTS \"{pending_table}\" CASCADE; \
5183 DROP SCHEMA IF EXISTS \"{migration_schema}\" CASCADE; \
5184 CREATE SCHEMA \"{migration_schema}\"; \
5185 CREATE TABLE \"{migration_schema}\".\"__drizzle_migrations\" (id SERIAL PRIMARY KEY, hash TEXT NOT NULL, created_at BIGINT); \
5186 CREATE TABLE \"{applied_table}\" (id integer primary key);"
5187 ))
5188 .expect("setup legacy postgres tracking metadata");
5189
5190 let first = Migration::new(
5191 &format!("20230331141203_{applied_table}"),
5192 &format!("CREATE TABLE \"{applied_table}\" (id integer primary key);"),
5193 );
5194 let second = Migration::new(
5195 &format!("20230331141204_{pending_table}"),
5196 &format!("CREATE TABLE \"{pending_table}\" (id integer primary key);"),
5197 );
5198 let set = Migrations::with_tracking(
5199 vec![first.clone(), second.clone()],
5200 Dialect::PostgreSQL,
5201 drizzle_migrations::Tracking::POSTGRES.schema(migration_schema.clone()),
5202 );
5203
5204 client
5205 .execute(
5206 &format!(
5207 "INSERT INTO \"{migration_schema}\".\"__drizzle_migrations\" (hash, created_at) VALUES ($1, $2)"
5208 ),
5209 &[&first.hash(), &first.created_at()],
5210 )
5211 .expect("insert legacy applied migration row");
5212 drop(client);
5213
5214 let result = run_postgres_sync_migrations(&set, &creds)
5215 .expect("sync migration upgrade should succeed");
5216 assert_eq!(result.applied_count, 1);
5217 assert_eq!(result.applied_migrations, vec![second.hash().to_string()]);
5218
5219 let mut verify_client =
5220 postgres::Client::connect(&url, postgres::NoTls).expect("reconnect for verification");
5221 let columns: Vec<String> = verify_client
5222 .query(
5223 "SELECT column_name FROM information_schema.columns WHERE table_schema = $1 AND table_name = '__drizzle_migrations' ORDER BY ordinal_position",
5224 &[&migration_schema],
5225 )
5226 .expect("query upgraded tracking columns")
5227 .into_iter()
5228 .map(|row| row.get(0))
5229 .collect();
5230 assert_eq!(
5231 columns,
5232 vec!["id", "hash", "created_at", "name", "applied_at"]
5233 );
5234
5235 let rows: Vec<(String, i64, String, Option<String>)> = verify_client
5236 .query(
5237 &format!(
5238 "SELECT hash, created_at, name, applied_at::text FROM \"{migration_schema}\".\"__drizzle_migrations\" ORDER BY id ASC"
5239 ),
5240 &[],
5241 )
5242 .expect("query upgraded metadata rows")
5243 .into_iter()
5244 .map(|row| (row.get(0), row.get(1), row.get(2), row.get(3)))
5245 .collect();
5246 assert_eq!(rows.len(), 2);
5247 assert_eq!(rows[0].0, first.hash());
5248 assert_eq!(rows[0].1, first.created_at());
5249 assert_eq!(rows[0].2, first.name());
5250 assert_eq!(rows[0].3, None);
5251 assert_eq!(rows[1].0, second.hash());
5252 assert_eq!(rows[1].1, second.created_at());
5253 assert_eq!(rows[1].2, second.name());
5254 assert!(rows[1].3.is_some());
5255
5256 let pending_exists: i64 = verify_client
5257 .query_one(
5258 "SELECT COUNT(*)::bigint FROM information_schema.tables WHERE table_schema = 'public' AND table_name = $1",
5259 &[&pending_table],
5260 )
5261 .expect("query pending table")
5262 .get(0);
5263 assert_eq!(pending_exists, 1);
5264
5265 let _ = verify_client.batch_execute(&format!(
5266 "DROP TABLE IF EXISTS \"{applied_table}\" CASCADE; \
5267 DROP TABLE IF EXISTS \"{pending_table}\" CASCADE; \
5268 DROP SCHEMA IF EXISTS \"{migration_schema}\" CASCADE;"
5269 ));
5270 }
5271
5272 #[cfg(feature = "postgres-sync")]
5273 #[test]
5274 fn postgres_sync_migrate_upgrade_rejects_unmatched_legacy_rows() {
5275 use drizzle_migrations::{Migration, Migrations};
5276 use drizzle_types::Dialect;
5277
5278 let creds = test_postgres_creds();
5279 let url = creds.connection_url();
5280 let mut client = postgres::Client::connect(&url, postgres::NoTls)
5281 .expect("connect postgres for unmatched legacy row test");
5282
5283 let migration_schema = unique_pg_name("cli_sync_tracking_unmatched");
5284 client
5285 .batch_execute(&format!(
5286 "DROP SCHEMA IF EXISTS \"{migration_schema}\" CASCADE; \
5287 CREATE SCHEMA \"{migration_schema}\"; \
5288 CREATE TABLE \"{migration_schema}\".\"__drizzle_migrations\" (id SERIAL PRIMARY KEY, hash TEXT NOT NULL, created_at BIGINT);"
5289 ))
5290 .expect("setup unmatched legacy metadata");
5291
5292 let migration = Migration::new(
5293 "20230331141203_cli_sync_first",
5294 "CREATE TABLE \"cli_sync_unmatched_target\" (id integer primary key);",
5295 );
5296 let set = Migrations::with_tracking(
5297 vec![migration],
5298 Dialect::PostgreSQL,
5299 drizzle_migrations::Tracking::POSTGRES.schema(migration_schema.clone()),
5300 );
5301
5302 client
5303 .execute(
5304 &format!(
5305 "INSERT INTO \"{migration_schema}\".\"__drizzle_migrations\" (hash, created_at) VALUES ($1, $2)"
5306 ),
5307 &[&"unknown_hash", &1_680_271_924_000_i64],
5308 )
5309 .expect("insert unmatched legacy row");
5310 drop(client);
5311
5312 let err = run_postgres_sync_migrations(&set, &creds)
5313 .expect_err("unmatched legacy metadata should fail");
5314 assert!(err.to_string().contains("do not match local migrations"));
5315
5316 let mut verify_client =
5317 postgres::Client::connect(&url, postgres::NoTls).expect("reconnect for verification");
5318 let columns: Vec<String> = verify_client
5319 .query(
5320 "SELECT column_name FROM information_schema.columns WHERE table_schema = $1 AND table_name = '__drizzle_migrations' ORDER BY ordinal_position",
5321 &[&migration_schema],
5322 )
5323 .expect("query legacy tracking columns")
5324 .into_iter()
5325 .map(|row| row.get(0))
5326 .collect();
5327 assert_eq!(columns, vec!["id", "hash", "created_at"]);
5328
5329 let _ = verify_client.batch_execute(&format!(
5330 "DROP TABLE IF EXISTS \"cli_sync_unmatched_target\" CASCADE; \
5331 DROP SCHEMA IF EXISTS \"{migration_schema}\" CASCADE;"
5332 ));
5333 }
5334
5335 #[cfg(feature = "tokio-postgres")]
5336 #[test]
5337 fn tokio_postgres_migrate_applies_concurrent_index_without_transaction() {
5338 use drizzle_migrations::{Migration, Migrations};
5339 use drizzle_types::Dialect;
5340
5341 let creds = test_postgres_creds();
5342 let url = creds.connection_url();
5343 let rt = tokio::runtime::Builder::new_current_thread()
5344 .enable_all()
5345 .build()
5346 .expect("create tokio runtime");
5347
5348 rt.block_on(async {
5349 let (client, connection) = match tokio_postgres::connect(&url, tokio_postgres::NoTls).await
5350 {
5351 Ok(v) => v,
5352 Err(e) => {
5353 eprintln!(
5354 "Skipping tokio_postgres_migrate_applies_concurrent_index_without_transaction: {}",
5355 e
5356 );
5357 return;
5358 }
5359 };
5360
5361 tokio::spawn(async move {
5362 let _ = connection.await;
5363 });
5364
5365 let table = unique_pg_name("cli_async_users");
5366 let index = format!("{}_email_idx", table);
5367 let migration_schema = unique_pg_name("cli_async_mig");
5368
5369 client
5370 .batch_execute(&format!(
5371 "DROP TABLE IF EXISTS \"{table}\" CASCADE; \
5372 CREATE TABLE \"{table}\" (id integer, email text NOT NULL); \
5373 INSERT INTO \"{table}\" (id, email) VALUES (1, 'a@example.com');"
5374 ))
5375 .await
5376 .expect("setup table for async concurrent index test");
5377
5378 let migration_tag = format!("20260212000000_{table}");
5379 let migration_sql = format!(
5380 "CREATE INDEX CONCURRENTLY \"{index}\" ON \"{table}\" (\"email\");"
5381 );
5382 let set = Migrations::with_tracking(
5383 vec![Migration::new(&migration_tag, &migration_sql)],
5384 Dialect::PostgreSQL,
5385 drizzle_migrations::Tracking::POSTGRES.schema(migration_schema.clone()),
5386 );
5387
5388 let result = run_postgres_async_inner(&set, &creds)
5389 .await
5390 .expect("async migration with concurrent index should succeed");
5391 assert_eq!(result.applied_count, 1);
5392
5393 let exists: i64 = client
5394 .query_one(
5395 "SELECT COUNT(*)::bigint FROM pg_indexes \
5396 WHERE schemaname = 'public' AND tablename = $1 AND indexname = $2",
5397 &[&table, &index],
5398 )
5399 .await
5400 .expect("query pg_indexes")
5401 .get(0);
5402 assert_eq!(exists, 1, "async concurrent index was not created");
5403
5404 let _ = client
5405 .batch_execute(&format!(
5406 "DROP TABLE IF EXISTS \"{table}\" CASCADE; \
5407 DROP SCHEMA IF EXISTS \"{migration_schema}\" CASCADE;"
5408 ))
5409 .await;
5410 });
5411 }
5412
5413 #[cfg(feature = "tokio-postgres")]
5414 #[test]
5415 fn tokio_postgres_migrate_upgrades_legacy_tracking_table_and_applies_pending() {
5416 use drizzle_migrations::{Migration, Migrations};
5417 use drizzle_types::Dialect;
5418
5419 let creds = test_postgres_creds();
5420 let url = creds.connection_url();
5421 let rt = tokio::runtime::Builder::new_current_thread()
5422 .enable_all()
5423 .build()
5424 .expect("create tokio runtime");
5425
5426 rt.block_on(async {
5427 let (client, connection) = tokio_postgres::connect(&url, tokio_postgres::NoTls)
5428 .await
5429 .expect("connect tokio-postgres for legacy tracking upgrade test");
5430
5431 tokio::spawn(async move {
5432 let _ = connection.await;
5433 });
5434
5435 let applied_table = unique_pg_name("cli_async_applied");
5436 let pending_table = unique_pg_name("cli_async_pending");
5437 let migration_schema = unique_pg_name("cli_async_tracking");
5438
5439 client
5440 .batch_execute(&format!(
5441 "DROP TABLE IF EXISTS \"{applied_table}\" CASCADE; \
5442 DROP TABLE IF EXISTS \"{pending_table}\" CASCADE; \
5443 DROP SCHEMA IF EXISTS \"{migration_schema}\" CASCADE; \
5444 CREATE SCHEMA \"{migration_schema}\"; \
5445 CREATE TABLE \"{migration_schema}\".\"__drizzle_migrations\" (id SERIAL PRIMARY KEY, hash TEXT NOT NULL, created_at BIGINT); \
5446 CREATE TABLE \"{applied_table}\" (id integer primary key);"
5447 ))
5448 .await
5449 .expect("setup legacy postgres tracking metadata");
5450
5451 let first = Migration::new(
5452 &format!("20230331141203_{applied_table}"),
5453 &format!("CREATE TABLE \"{applied_table}\" (id integer primary key);"),
5454 );
5455 let second = Migration::new(
5456 &format!("20230331141204_{pending_table}"),
5457 &format!("CREATE TABLE \"{pending_table}\" (id integer primary key);"),
5458 );
5459 let set = Migrations::with_tracking(
5460 vec![first.clone(), second.clone()],
5461 Dialect::PostgreSQL,
5462 drizzle_migrations::Tracking::POSTGRES.schema(migration_schema.clone()),
5463 );
5464
5465 client
5466 .execute(
5467 &format!(
5468 "INSERT INTO \"{migration_schema}\".\"__drizzle_migrations\" (hash, created_at) VALUES ($1, $2)"
5469 ),
5470 &[&first.hash(), &first.created_at()],
5471 )
5472 .await
5473 .expect("insert legacy applied migration row");
5474
5475 let result = run_postgres_async_inner(&set, &creds)
5476 .await
5477 .expect("async migration upgrade should succeed");
5478 assert_eq!(result.applied_count, 1);
5479 assert_eq!(result.applied_migrations, vec![second.hash().to_string()]);
5480
5481 let columns: Vec<String> = client
5482 .query(
5483 "SELECT column_name FROM information_schema.columns WHERE table_schema = $1 AND table_name = '__drizzle_migrations' ORDER BY ordinal_position",
5484 &[&migration_schema],
5485 )
5486 .await
5487 .expect("query upgraded tracking columns")
5488 .into_iter()
5489 .map(|row| row.get(0))
5490 .collect();
5491 assert_eq!(
5492 columns,
5493 vec!["id", "hash", "created_at", "name", "applied_at"]
5494 );
5495
5496 let rows: Vec<(String, i64, String, Option<String>)> = client
5497 .query(
5498 &format!(
5499 "SELECT hash, created_at, name, applied_at::text FROM \"{migration_schema}\".\"__drizzle_migrations\" ORDER BY id ASC"
5500 ),
5501 &[],
5502 )
5503 .await
5504 .expect("query upgraded metadata rows")
5505 .into_iter()
5506 .map(|row| (row.get(0), row.get(1), row.get(2), row.get(3)))
5507 .collect();
5508 assert_eq!(rows.len(), 2);
5509 assert_eq!(rows[0].0, first.hash());
5510 assert_eq!(rows[0].1, first.created_at());
5511 assert_eq!(rows[0].2, first.name());
5512 assert_eq!(rows[0].3, None);
5513 assert_eq!(rows[1].0, second.hash());
5514 assert_eq!(rows[1].1, second.created_at());
5515 assert_eq!(rows[1].2, second.name());
5516 assert!(rows[1].3.is_some());
5517
5518 let pending_exists: i64 = client
5519 .query_one(
5520 "SELECT COUNT(*)::bigint FROM information_schema.tables WHERE table_schema = 'public' AND table_name = $1",
5521 &[&pending_table],
5522 )
5523 .await
5524 .expect("query pending table")
5525 .get(0);
5526 assert_eq!(pending_exists, 1);
5527
5528 let _ = client
5529 .batch_execute(&format!(
5530 "DROP TABLE IF EXISTS \"{applied_table}\" CASCADE; \
5531 DROP TABLE IF EXISTS \"{pending_table}\" CASCADE; \
5532 DROP SCHEMA IF EXISTS \"{migration_schema}\" CASCADE;"
5533 ))
5534 .await;
5535 });
5536 }
5537}