Skip to main content

drizzle_cli/db/
mod.rs

1//! Database connection and migration execution for CLI commands
2//!
3//! This module provides database connectivity for running migrations and other
4//! database operations from the CLI.
5
6use std::path::Path;
7
8use crate::commands::overrides::ResolvedConnection;
9#[cfg(any(feature = "postgres-sync", feature = "tokio-postgres"))]
10use crate::config::PostgresCreds;
11use crate::config::{Credentials, Dialect, Driver, Extension, IntrospectCasing};
12use crate::error::CliError;
13use crate::output;
14use drizzle_migrations::Migrations;
15use drizzle_migrations::schema::Snapshot;
16
17#[cfg(feature = "d1-http")]
18mod d1_http;
19mod filters;
20mod mysql;
21
22pub use filters::apply_snapshot_filters;
23#[cfg(test)]
24use filters::{compile_patterns, matches_patterns};
25
26/// Result of a migration run
27#[derive(Debug, Default)]
28pub struct MigrationResult {
29    /// Number of migrations applied
30    pub applied_count: usize,
31    /// Tags of applied migrations
32    pub applied_migrations: Vec<String>,
33    /// Tags of interrupted migrations reconciled by `--repair`.
34    pub repaired_migrations: Vec<String>,
35}
36
37/// Planned migration execution details.
38#[derive(Debug, Clone)]
39pub struct MigrationPlan {
40    /// Number of already-applied migrations found in the database metadata table.
41    pub applied_count: usize,
42    /// Number of pending migrations found locally.
43    pub pending_count: usize,
44    /// Pending migration tags in execution order.
45    pub pending_migrations: Vec<String>,
46    /// Total number of non-empty SQL statements in pending migrations.
47    pub pending_statements: usize,
48    /// Integrity findings: hash drift between applied rows and local files,
49    /// applied rows with no local migration, duplicate tracking rows, and
50    /// interrupted (dirty) migrations. `--plan` reports these as warnings;
51    /// `--verify` and `--safe` treat any finding as failure.
52    pub findings: Vec<String>,
53}
54
55/// A tracking table as `migrate --plan`, `--dry-run` and `--verify` read it:
56/// they must not create or upgrade it.
57#[cfg(any(
58    feature = "rusqlite",
59    feature = "libsql",
60    feature = "turso",
61    feature = "postgres-sync",
62    feature = "tokio-postgres",
63    feature = "mysql-sync",
64    feature = "mysql-async",
65))]
66pub(crate) enum TrackingState {
67    /// No tracking table: no migration has run.
68    Missing,
69    /// A table from before migrations were recorded by name, as its rows.
70    Legacy(Vec<drizzle_migrations::AppliedMigrationMetadata>),
71    /// The current layout.
72    Current,
73}
74
75/// The applied migrations of a legacy tracking table, matched to the local
76/// ones by timestamp and hash the way `migrate` upgrades the table.
77#[cfg(any(
78    feature = "rusqlite",
79    feature = "libsql",
80    feature = "turso",
81    feature = "postgres-sync",
82    feature = "tokio-postgres",
83    feature = "mysql-sync",
84    feature = "mysql-async",
85))]
86pub(crate) fn legacy_applied_records(
87    set: &Migrations,
88    rows: &[drizzle_migrations::AppliedMigrationMetadata],
89) -> Result<Vec<AppliedMigrationRecord>, CliError> {
90    let matched = drizzle_migrations::match_applied_migration_metadata(set.all(), rows)
91        .map_err(|e| CliError::MigrationError(e.to_string()))?;
92    Ok(matched
93        .into_iter()
94        .map(|row| AppliedMigrationRecord {
95            hash: row.hash,
96            name: row.name,
97            dirty: false,
98        })
99        .collect())
100}
101
102/// Whether a SQLite database named by `path` exists. Opening a missing file
103/// would create it; `:memory:` and `file:` URIs are left to the driver.
104#[cfg(any(feature = "rusqlite", feature = "libsql"))]
105fn sqlite_database_exists(path: &str) -> bool {
106    path == ":memory:" || path.starts_with("file:") || std::path::Path::new(path).exists()
107}
108
109#[cfg(any(
110    feature = "rusqlite",
111    feature = "libsql",
112    feature = "turso",
113    feature = "postgres-sync",
114    feature = "tokio-postgres",
115    feature = "mysql-sync",
116    feature = "mysql-async",
117    feature = "d1-http",
118))]
119#[derive(Debug, Clone)]
120pub(crate) struct AppliedMigrationRecord {
121    pub(crate) hash: String,
122    pub(crate) name: String,
123    /// Row was started but never marked finished (`applied_at IS NULL`).
124    pub(crate) dirty: bool,
125}
126
127/// Planned SQL changes for `drizzle push`
128#[derive(Debug, Clone)]
129pub struct PushPlan {
130    pub sql_statements: Vec<String>,
131    pub warnings: Vec<String>,
132    pub destructive: bool,
133}
134
135/// Optional filters for introspection and push planning.
136#[derive(Debug, Clone, Default)]
137pub struct SnapshotFilters {
138    pub tables: Option<Vec<String>>,
139    pub schemas: Option<Vec<String>>,
140    pub extensions: Option<Vec<Extension>>,
141    /// Which `PostgreSQL` roles (and their privileges) to keep, from
142    /// `entities.roles`. `None` leaves roles as they are.
143    pub roles: Option<crate::config::RolesFilter>,
144}
145
146fn connection_driver_mismatch(connection: &ResolvedConnection) -> CliError {
147    CliError::Other(format!(
148        "driver '{}' cannot use the resolved {} credentials",
149        connection.driver, connection.dialect
150    ))
151}
152
153impl SnapshotFilters {
154    const fn is_empty(&self) -> bool {
155        self.tables.is_none() && self.schemas.is_none() && self.extensions.is_none()
156    }
157}
158
159/// Plan a push by introspecting the live database and diffing against the desired snapshot.
160///
161/// # Errors
162///
163/// Returns [`CliError`] if introspecting the live database fails, if applying
164/// the given snapshot filters fails, or if generating the diff SQL fails.
165pub fn plan_push(
166    connection: &ResolvedConnection,
167    desired: &Snapshot,
168    breakpoints: bool,
169    filters: &SnapshotFilters,
170    migrations_table: &str,
171) -> Result<PushPlan, CliError> {
172    let introspected = introspect_database(connection)?;
173    let mut current = introspected.snapshot;
174    exclude_tracking_table(&mut current, connection.dialect, migrations_table)?;
175    apply_snapshot_filters(&mut current, connection.dialect, filters)?;
176    let mut options = drizzle_migrations::DiffOptions::new();
177    if let Some(defaults) = introspected.mysql_catalog_defaults {
178        options = options.mysql_catalog_defaults(defaults);
179    }
180    let (sql_statements, warnings) = generate_push_sql(&current, desired, breakpoints, &options)?;
181    let destructive = push_requires_confirmation(connection.dialect, &sql_statements, &warnings);
182
183    Ok(PushPlan {
184        sql_statements,
185        warnings,
186        destructive,
187    })
188}
189
190fn exclude_tracking_table(
191    snapshot: &mut Snapshot,
192    dialect: Dialect,
193    migrations_table: &str,
194) -> Result<(), CliError> {
195    if migrations_table.trim().is_empty() {
196        return Ok(());
197    }
198    apply_snapshot_filters(
199        snapshot,
200        dialect,
201        &SnapshotFilters {
202            tables: Some(vec![format!(
203                "!{}",
204                glob::Pattern::escape(migrations_table)
205            )]),
206            schemas: None,
207            extensions: None,
208            roles: None,
209        },
210    )
211}
212
213/// Apply a previously planned push.
214///
215/// # Errors
216///
217/// Returns [`CliError::Aborted`] if the user declines the confirmation prompt
218/// for a destructive plan, and [`CliError`] if the prompt itself fails or if
219/// executing the planned SQL statements against the database fails.
220pub fn apply_push(
221    connection: &ResolvedConnection,
222    plan: &PushPlan,
223    force: bool,
224) -> Result<(), CliError> {
225    apply_push_with_confirmation(connection, plan, force, confirm_destructive)
226}
227
228/// [`apply_push`] with the destructive-change prompt supplied by the caller.
229fn apply_push_with_confirmation(
230    connection: &ResolvedConnection,
231    plan: &PushPlan,
232    force: bool,
233    confirm: impl FnOnce() -> Result<bool, CliError>,
234) -> Result<(), CliError> {
235    if plan.sql_statements.is_empty() {
236        return Ok(());
237    }
238
239    if plan.destructive && !force && !confirm()? {
240        return Err(CliError::Aborted(
241            "Push aborted: the destructive changes were not confirmed, so nothing was applied"
242                .into(),
243        ));
244    }
245
246    execute_statements(connection, &plan.sql_statements)
247}
248
249/// Execute migrations against the database
250///
251/// This is the main entry point that dispatches to the appropriate driver
252/// based on the credentials type.
253///
254/// # Errors
255///
256/// Returns [`CliError`] if no compiled driver matches the credentials, if
257/// connecting to the database fails, or if reading the migration tracking
258/// table or on-disk migration files fails.
259#[allow(unused_variables)] // params consumed inside feature-gated block
260pub fn plan_migrations(
261    connection: &ResolvedConnection,
262    migrations_dir: &Path,
263    migrations_table: &str,
264    migrations_schema: &str,
265) -> Result<MigrationPlan, CliError> {
266    let set = load_migration_set(
267        connection.dialect,
268        migrations_dir,
269        migrations_table,
270        migrations_schema,
271    )?;
272
273    match &connection.credentials {
274        #[cfg(feature = "rusqlite")]
275        Credentials::Sqlite { path } => inspect_sqlite_migrations(&set, path),
276
277        #[cfg(not(feature = "rusqlite"))]
278        Credentials::Sqlite { .. } => Err(CliError::MissingDriver {
279            dialect: "SQLite",
280            feature: "rusqlite",
281        }),
282
283        #[cfg(any(feature = "libsql", feature = "turso"))]
284        Credentials::Turso { url, auth_token } => {
285            if connection.driver == Driver::Libsql {
286                #[cfg(feature = "libsql")]
287                {
288                    inspect_libsql_local_migrations(&set, url)
289                }
290                #[cfg(not(feature = "libsql"))]
291                {
292                    Err(CliError::MissingDriver {
293                        dialect: "LibSQL (local)",
294                        feature: "libsql",
295                    })
296                }
297            } else if connection.driver == Driver::Turso {
298                #[cfg(feature = "turso")]
299                {
300                    inspect_turso_migrations(&set, url, auth_token.as_deref())
301                }
302                #[cfg(not(feature = "turso"))]
303                {
304                    let _ = auth_token;
305                    Err(CliError::MissingDriver {
306                        dialect: "Turso (remote)",
307                        feature: "turso",
308                    })
309                }
310            } else {
311                Err(connection_driver_mismatch(connection))
312            }
313        }
314
315        #[cfg(all(not(feature = "turso"), not(feature = "libsql")))]
316        Credentials::Turso { .. } => Err(CliError::MissingDriver {
317            dialect: "Turso",
318            feature: "turso or libsql",
319        }),
320
321        Credentials::Postgres(creds) => {
322            let _ = creds;
323            match connection.driver {
324                Driver::PostgresSync => {
325                    #[cfg(feature = "postgres-sync")]
326                    {
327                        inspect_postgres_sync_migrations(&set, creds)
328                    }
329                    #[cfg(not(feature = "postgres-sync"))]
330                    {
331                        Err(CliError::MissingDriver {
332                            dialect: "PostgreSQL",
333                            feature: "postgres-sync",
334                        })
335                    }
336                }
337                Driver::TokioPostgres => {
338                    #[cfg(feature = "tokio-postgres")]
339                    {
340                        inspect_postgres_async_migrations(&set, creds)
341                    }
342                    #[cfg(not(feature = "tokio-postgres"))]
343                    {
344                        Err(CliError::MissingDriver {
345                            dialect: "PostgreSQL",
346                            feature: "tokio-postgres",
347                        })
348                    }
349                }
350                _ => Err(connection_driver_mismatch(connection)),
351            }
352        }
353
354        Credentials::MySQL(creds) => mysql::plan_migrations(connection.driver, creds, &set),
355
356        #[cfg(feature = "d1-http")]
357        Credentials::D1 {
358            account_id,
359            database_id,
360            token,
361        } => d1_http::inspect_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: "Migration planning against AWS Data API",
371            driver: "aws-data-api",
372            hint: "AWS RDS Data API schema ops are not yet wired into this CLI. For now, \
373                   run the generated SQL with `aws rds-data execute-statement --sql=...` \
374                   (or use tokio-postgres with a temporary direct connection).",
375        }),
376    }
377}
378
379/// Apply any pending migrations against the database referenced by
380/// `credentials`.
381///
382/// # Errors
383///
384/// Returns [`CliError`] if no compiled driver matches, if connecting or
385/// starting a transaction fails, if executing a migration's SQL fails, or if
386/// writing to the tracking table fails.
387#[allow(unused_variables)] // params consumed inside feature-gated block
388pub fn run_migrations(
389    connection: &ResolvedConnection,
390    migrations_dir: &Path,
391    migrations_table: &str,
392    migrations_schema: &str,
393    repair: bool,
394) -> Result<MigrationResult, CliError> {
395    let set = load_migration_set(
396        connection.dialect,
397        migrations_dir,
398        migrations_table,
399        migrations_schema,
400    )?;
401
402    match &connection.credentials {
403        #[cfg(feature = "rusqlite")]
404        Credentials::Sqlite { path } => run_sqlite_migrations(&set, path, repair),
405
406        #[cfg(not(feature = "rusqlite"))]
407        Credentials::Sqlite { .. } => Err(CliError::MissingDriver {
408            dialect: "SQLite",
409            feature: "rusqlite",
410        }),
411
412        #[cfg(any(feature = "libsql", feature = "turso"))]
413        Credentials::Turso { url, auth_token } => {
414            if connection.driver == Driver::Libsql {
415                #[cfg(feature = "libsql")]
416                {
417                    run_libsql_local_migrations(&set, url, repair)
418                }
419                #[cfg(not(feature = "libsql"))]
420                {
421                    Err(CliError::MissingDriver {
422                        dialect: "LibSQL (local)",
423                        feature: "libsql",
424                    })
425                }
426            } else if connection.driver == Driver::Turso {
427                #[cfg(feature = "turso")]
428                {
429                    run_turso_migrations(&set, url, auth_token.as_deref(), repair)
430                }
431                #[cfg(not(feature = "turso"))]
432                {
433                    let _ = auth_token;
434                    Err(CliError::MissingDriver {
435                        dialect: "Turso (remote)",
436                        feature: "turso",
437                    })
438                }
439            } else {
440                Err(connection_driver_mismatch(connection))
441            }
442        }
443
444        #[cfg(all(not(feature = "turso"), not(feature = "libsql")))]
445        Credentials::Turso { .. } => Err(CliError::MissingDriver {
446            dialect: "Turso",
447            feature: "turso or libsql",
448        }),
449
450        Credentials::Postgres(creds) => {
451            let _ = creds;
452            match connection.driver {
453                Driver::PostgresSync => {
454                    #[cfg(feature = "postgres-sync")]
455                    {
456                        run_postgres_sync_migrations(&set, creds, repair)
457                    }
458                    #[cfg(not(feature = "postgres-sync"))]
459                    {
460                        Err(CliError::MissingDriver {
461                            dialect: "PostgreSQL",
462                            feature: "postgres-sync",
463                        })
464                    }
465                }
466                Driver::TokioPostgres => {
467                    #[cfg(feature = "tokio-postgres")]
468                    {
469                        run_postgres_async_migrations(&set, creds, repair)
470                    }
471                    #[cfg(not(feature = "tokio-postgres"))]
472                    {
473                        Err(CliError::MissingDriver {
474                            dialect: "PostgreSQL",
475                            feature: "tokio-postgres",
476                        })
477                    }
478                }
479                _ => Err(connection_driver_mismatch(connection)),
480            }
481        }
482
483        Credentials::MySQL(creds) => mysql::run_migrations(connection.driver, creds, &set, repair),
484
485        #[cfg(feature = "d1-http")]
486        Credentials::D1 {
487            account_id,
488            database_id,
489            token,
490        } => d1_http::run_migrations(&set, account_id, database_id, token, repair),
491
492        #[cfg(not(feature = "d1-http"))]
493        Credentials::D1 { .. } => Err(CliError::MissingDriver {
494            dialect: "Cloudflare D1 (HTTP)",
495            feature: "d1-http",
496        }),
497
498        Credentials::AwsDataApi { .. } => Err(CliError::UnsupportedForDriver {
499            operation: "Running migrations against AWS Data API",
500            driver: "aws-data-api",
501            hint: "AWS RDS Data API migrations are not yet wired into this CLI. Run the \
502                   generated SQL via `aws rds-data execute-statement` or connect directly \
503                   with tokio-postgres.",
504        }),
505    }
506}
507
508fn load_migration_set(
509    dialect: Dialect,
510    migrations_dir: &Path,
511    migrations_table: &str,
512    migrations_schema: &str,
513) -> Result<Migrations, CliError> {
514    let tracking = migration_tracking(dialect, migrations_table, migrations_schema);
515
516    // Load migrations from filesystem
517    let migrations = drizzle_migrations::MigrationDir::new(migrations_dir)
518        .discover()
519        .map_err(|e| CliError::Other(format!("Failed to load migrations: {e}")))?;
520    Ok(Migrations::with_tracking(
521        migrations,
522        dialect.to_base(),
523        tracking,
524    ))
525}
526
527fn migration_tracking(
528    dialect: Dialect,
529    migrations_table: &str,
530    migrations_schema: &str,
531) -> drizzle_types::MigrationTracking {
532    let mut tracking = match dialect {
533        Dialect::Postgresql => drizzle_types::MigrationTracking::POSTGRES,
534        Dialect::Mysql => drizzle_types::MigrationTracking::MYSQL,
535        Dialect::Sqlite | Dialect::Turso => drizzle_types::MigrationTracking::SQLITE,
536    };
537
538    if !migrations_table.trim().is_empty() {
539        tracking = tracking.table(migrations_table.to_owned());
540    }
541
542    if dialect == Dialect::Postgresql && !migrations_schema.trim().is_empty() {
543        tracking = tracking.schema(migrations_schema.to_owned());
544    }
545
546    tracking
547}
548
549#[cfg(any(
550    feature = "rusqlite",
551    feature = "libsql",
552    feature = "turso",
553    feature = "postgres-sync",
554    feature = "tokio-postgres",
555    feature = "mysql-sync",
556    feature = "mysql-async",
557    feature = "d1-http",
558))]
559pub(crate) fn build_migration_plan(
560    set: &Migrations,
561    applied: &[AppliedMigrationRecord],
562) -> Result<MigrationPlan, CliError> {
563    let findings = collect_integrity_findings(set, applied)?;
564
565    // Dirty rows are not applied: the migration started but never finished.
566    let applied_names = applied
567        .iter()
568        .filter(|m| !m.dirty)
569        .map(|m| m.name.clone())
570        .collect::<Vec<_>>();
571    let pending = set.pending(&applied_names).collect::<Vec<_>>();
572
573    let pending_statements = pending
574        .iter()
575        .map(|m| {
576            m.statements()
577                .iter()
578                .filter(|stmt| !stmt.trim().is_empty())
579                .count()
580        })
581        .sum();
582
583    Ok(MigrationPlan {
584        applied_count: applied_names.len(),
585        pending_count: pending.len(),
586        pending_migrations: pending.iter().map(|m| m.tag().to_string()).collect(),
587        pending_statements,
588        findings,
589    })
590}
591
592/// Collect integrity findings between the tracking table and local files.
593///
594/// Findings (drift, missing-local rows, duplicate tracking rows, interrupted
595/// migrations) are returned rather than raised: `--plan` shows them as
596/// warnings while `--verify`/`--safe` fail on any. A duplicate name in the
597/// LOCAL migration set is still a hard error — the local set is broken and
598/// no comparison against it is meaningful.
599#[cfg(any(
600    feature = "rusqlite",
601    feature = "libsql",
602    feature = "turso",
603    feature = "postgres-sync",
604    feature = "tokio-postgres",
605    feature = "mysql-sync",
606    feature = "mysql-async",
607    feature = "d1-http",
608))]
609fn collect_integrity_findings(
610    set: &Migrations,
611    applied: &[AppliedMigrationRecord],
612) -> Result<Vec<String>, CliError> {
613    use std::collections::{HashMap, HashSet};
614
615    let mut local_by_name = HashMap::<&str, &str>::new();
616    for migration in set.all() {
617        if local_by_name
618            .insert(migration.name(), migration.hash())
619            .is_some()
620        {
621            return Err(CliError::MigrationError(format!(
622                "Local migrations contain duplicate name: {}",
623                migration.name()
624            )));
625        }
626    }
627
628    let mut findings = Vec::new();
629    let mut seen_db_names = HashSet::<&str>::new();
630    for applied_row in applied {
631        if !seen_db_names.insert(applied_row.name.as_str()) {
632            findings.push(format!(
633                "duplicate tracking rows for `{}` in the migrations table",
634                applied_row.name
635            ));
636            continue;
637        }
638
639        if applied_row.dirty {
640            let recovery = if set.dialect() == drizzle_types::Dialect::MySQL {
641                "inspect the partially applied DDL and resolve it manually"
642            } else {
643                "run `drizzle migrate --repair` or resolve it manually"
644            };
645            findings.push(format!(
646                "`{}` was interrupted mid-apply (started but never finished) — {recovery}",
647                applied_row.name,
648            ));
649        }
650
651        let Some(local_hash) = local_by_name.get(applied_row.name.as_str()) else {
652            findings.push(format!(
653                "`{}` is recorded in the database but no local migration has that name",
654                applied_row.name
655            ));
656            continue;
657        };
658
659        if *local_hash != applied_row.hash {
660            findings.push(format!(
661                "`{}` has drifted: the local migration file no longer matches what \
662                 was applied (database hash {}, local hash {})",
663                applied_row.name, applied_row.hash, local_hash
664            ));
665        }
666    }
667
668    Ok(findings)
669}
670
671#[cfg(any(feature = "rusqlite", feature = "libsql", feature = "turso"))]
672fn escape_sql_literal(value: &str) -> String {
673    value.replace('\'', "''")
674}
675
676// ============================================================================
677// Interrupted ("dirty") migration handling
678// ============================================================================
679//
680// A tracking row with a non-null `name` and a NULL `applied_at` means an
681// earlier run recorded a migration as started and never recorded it as
682// finished — the database may hold part of that migration's DDL. Every runner
683// checks for those rows before applying anything; `--repair` additionally
684// reconciles them statement-by-statement against live introspection.
685
686#[cfg(any(
687    feature = "rusqlite",
688    feature = "libsql",
689    feature = "turso",
690    feature = "postgres-sync",
691    feature = "tokio-postgres",
692))]
693fn migrator_cli_error(error: drizzle_migrations::MigratorError) -> CliError {
694    CliError::MigrationError(error.to_string())
695}
696
697/// Turn a dirty-name list into either an error (no `--repair`) or the list of
698/// migrations to reconcile.
699#[cfg(any(
700    feature = "rusqlite",
701    feature = "libsql",
702    feature = "turso",
703    feature = "postgres-sync",
704    feature = "tokio-postgres",
705))]
706fn dirty_migrations_to_repair<'a>(
707    set: &'a Migrations,
708    dirty: &[String],
709    repair: bool,
710) -> Result<Vec<&'a drizzle_migrations::Migration>, CliError> {
711    if dirty.is_empty() {
712        return Ok(Vec::new());
713    }
714    if !repair {
715        return Err(migrator_cli_error(
716            set.interrupted_migration_error(dirty)
717                .expect("dirty list is non-empty"),
718        ));
719    }
720    set.resolve_dirty_migrations(dirty)
721        .map_err(migrator_cli_error)
722}
723
724#[cfg(any(
725    feature = "rusqlite",
726    feature = "libsql",
727    feature = "turso",
728    feature = "d1-http",
729))]
730fn sqlite_execution_cli_error(
731    error: drizzle_migrations::SqliteMigrationExecutionError,
732) -> CliError {
733    CliError::MigrationError(error.to_string())
734}
735
736#[cfg(any(
737    feature = "rusqlite",
738    feature = "libsql",
739    feature = "turso",
740    feature = "d1-http",
741))]
742#[cfg(feature = "d1-http")]
743pub(super) fn reject_sqlite_foreign_key_suspension<'a>(
744    migrations: impl IntoIterator<Item = &'a drizzle_migrations::Migration>,
745    adapter: &str,
746) -> Result<(), CliError> {
747    let executions = migrations
748        .into_iter()
749        .map(drizzle_migrations::Migration::sqlite_execution)
750        .collect::<Result<Vec<_>, _>>()
751        .map_err(sqlite_execution_cli_error)?;
752    if executions
753        .iter()
754        .any(|execution| execution.suspends_foreign_keys())
755    {
756        return Err(CliError::MigrationError(format!(
757            "{adapter} cannot execute SQLite table rebuilds that suspend foreign keys"
758        )));
759    }
760    Ok(())
761}
762
763#[cfg(any(feature = "rusqlite", feature = "libsql", feature = "turso"))]
764fn ensure_sqlite_dirty_repairs_are_safe(
765    migrations: &[&drizzle_migrations::Migration],
766) -> Result<(), CliError> {
767    for migration in migrations {
768        let execution = migration
769            .sqlite_execution()
770            .map_err(sqlite_execution_cli_error)?;
771        if execution.suspends_foreign_keys() {
772            return Err(CliError::MigrationError(format!(
773                "Migration '{}' cannot be repaired safely: foreign-key-suspending table rebuilds require the original connection-owned transaction scope",
774                migration.tag()
775            )));
776        }
777    }
778    Ok(())
779}
780
781#[cfg(any(feature = "rusqlite", feature = "libsql", feature = "turso"))]
782fn finish_sqlite_foreign_key_scope<T>(
783    result: Result<T, CliError>,
784    restore: Result<(), CliError>,
785) -> Result<T, CliError> {
786    match (result, restore) {
787        (Err(primary), Err(restore)) => Err(CliError::MigrationError(format!(
788            "{primary}; additionally failed to restore SQLite foreign-key enforcement: {restore}"
789        ))),
790        (Err(primary), Ok(())) => Err(primary),
791        (Ok(_), Err(restore)) => Err(restore),
792        (Ok(value), Ok(())) => Ok(value),
793    }
794}
795
796#[cfg(feature = "rusqlite")]
797fn introspect_catalog_sqlite(
798    conn: &rusqlite::Connection,
799) -> Result<drizzle_migrations::repair::Catalog, CliError> {
800    let mut stmt = conn
801        .prepare(drizzle_migrations::repair::sqlite::OBJECTS_QUERY)
802        .map_err(|e| CliError::MigrationError(e.to_string()))?;
803    let rows = stmt
804        .query_map([], |row| {
805            Ok((
806                row.get::<_, String>(0)?,
807                row.get::<_, String>(1)?,
808                row.get::<_, Option<String>>(2)?,
809            ))
810        })
811        .map_err(|e| CliError::MigrationError(e.to_string()))?;
812
813    let rows = rows
814        .collect::<Result<Vec<_>, _>>()
815        .map_err(|e| CliError::MigrationError(e.to_string()))?;
816    Ok(drizzle_migrations::repair::sqlite::catalog(&rows))
817}
818
819#[cfg(feature = "rusqlite")]
820fn repair_dirty_migrations_sqlite(
821    conn: &rusqlite::Connection,
822    set: &Migrations,
823    repair: bool,
824) -> Result<Vec<String>, CliError> {
825    let dirty = {
826        let mut stmt = match conn.prepare(&set.dirty_names_sql()) {
827            Ok(stmt) => stmt,
828            Err(error) if is_sqlite_missing_table(&error) => return Ok(Vec::new()),
829            Err(error) => return Err(CliError::MigrationError(error.to_string())),
830        };
831        stmt.query_map([], |row| row.get::<_, String>(0))
832            .map_err(|e| CliError::MigrationError(e.to_string()))?
833            .collect::<Result<Vec<String>, _>>()
834            .map_err(|e| CliError::MigrationError(e.to_string()))?
835    };
836
837    let migrations = dirty_migrations_to_repair(set, &dirty, repair)?;
838    ensure_sqlite_dirty_repairs_are_safe(&migrations)?;
839    let table_ident = set.table_ident_sql();
840    let mut repaired = Vec::new();
841    for migration in migrations {
842        let catalog = introspect_catalog_sqlite(conn)?;
843        let plan =
844            drizzle_migrations::repair::plan(drizzle_types::Dialect::SQLite, migration, &catalog);
845        for statement in plan
846            .into_executable(&table_ident)
847            .map_err(migrator_cli_error)?
848        {
849            conn.execute(&statement, [])
850                .map_err(|e| CliError::MigrationError(format!("Repair statement failed: {e}")))?;
851        }
852        conn.execute(&set.record_migration_finished_sql(migration), [])
853            .map_err(|e| CliError::MigrationError(e.to_string()))?;
854        repaired.push(migration.tag().to_string());
855    }
856    Ok(repaired)
857}
858
859#[cfg(any(feature = "libsql", feature = "turso"))]
860async fn introspect_catalog_libsql(
861    conn: &libsql::Connection,
862) -> Result<drizzle_migrations::repair::Catalog, CliError> {
863    let mut rows = conn
864        .query(drizzle_migrations::repair::sqlite::OBJECTS_QUERY, ())
865        .await
866        .map_err(|e| CliError::MigrationError(e.to_string()))?;
867
868    let mut master_rows = Vec::new();
869    while let Some(row) = rows
870        .next()
871        .await
872        .map_err(|e| CliError::MigrationError(e.to_string()))?
873    {
874        master_rows.push((
875            row.get::<String>(0)
876                .map_err(|e| CliError::MigrationError(e.to_string()))?,
877            row.get::<String>(1)
878                .map_err(|e| CliError::MigrationError(e.to_string()))?,
879            row.get::<Option<String>>(2).ok().flatten(),
880        ));
881    }
882    Ok(drizzle_migrations::repair::sqlite::catalog(&master_rows))
883}
884
885#[cfg(any(feature = "libsql", feature = "turso"))]
886async fn repair_dirty_migrations_libsql(
887    conn: &libsql::Connection,
888    set: &Migrations,
889    repair: bool,
890) -> Result<Vec<String>, CliError> {
891    let mut rows = conn
892        .query(&set.dirty_names_sql(), ())
893        .await
894        .map_err(|e| CliError::MigrationError(e.to_string()))?;
895    let mut dirty = Vec::new();
896    while let Some(row) = rows
897        .next()
898        .await
899        .map_err(|e| CliError::MigrationError(e.to_string()))?
900    {
901        dirty.push(
902            row.get::<String>(0)
903                .map_err(|e| CliError::MigrationError(e.to_string()))?,
904        );
905    }
906
907    let migrations = dirty_migrations_to_repair(set, &dirty, repair)?;
908    ensure_sqlite_dirty_repairs_are_safe(&migrations)?;
909    let table_ident = set.table_ident_sql();
910    let mut repaired = Vec::new();
911    for migration in migrations {
912        let catalog = introspect_catalog_libsql(conn).await?;
913        let plan =
914            drizzle_migrations::repair::plan(drizzle_types::Dialect::SQLite, migration, &catalog);
915        for statement in plan
916            .into_executable(&table_ident)
917            .map_err(migrator_cli_error)?
918        {
919            conn.execute(&statement, ())
920                .await
921                .map_err(|e| CliError::MigrationError(format!("Repair statement failed: {e}")))?;
922        }
923        conn.execute(&set.record_migration_finished_sql(migration), ())
924            .await
925            .map_err(|e| CliError::MigrationError(e.to_string()))?;
926        repaired.push(migration.tag().to_string());
927    }
928    Ok(repaired)
929}
930
931#[cfg(feature = "postgres-sync")]
932fn introspect_catalog_postgres_sync(
933    client: &mut postgres::Client,
934) -> Result<drizzle_migrations::repair::Catalog, CliError> {
935    use drizzle_migrations::repair::postgres as pg;
936
937    let err = |e: postgres::Error| CliError::MigrationError(e.to_string());
938    let two =
939        |client: &mut postgres::Client, sql: &str| -> Result<Vec<(String, String)>, CliError> {
940            Ok(client
941                .query(sql, &[])
942                .map_err(err)?
943                .iter()
944                .map(|row| (row.get::<_, String>(0), row.get::<_, String>(1)))
945                .collect())
946        };
947    let three = |client: &mut postgres::Client,
948                 sql: &str|
949     -> Result<Vec<(String, String, String)>, CliError> {
950        Ok(client
951            .query(sql, &[])
952            .map_err(err)?
953            .iter()
954            .map(|row| {
955                (
956                    row.get::<_, String>(0),
957                    row.get::<_, String>(1),
958                    row.get::<_, String>(2),
959                )
960            })
961            .collect())
962    };
963
964    let indexes = client
965        .query(pg::INDEXES_QUERY, &[])
966        .map_err(err)?
967        .iter()
968        .map(|row| {
969            (
970                row.get::<_, String>(0),
971                row.get::<_, String>(1),
972                row.get::<_, bool>(2),
973            )
974        })
975        .collect::<Vec<_>>();
976
977    let tables = two(client, pg::TABLES_QUERY)?;
978    let columns = three(client, pg::COLUMNS_QUERY)?;
979    let views = two(client, pg::VIEWS_QUERY)?;
980    let index_columns = three(client, pg::INDEX_COLUMNS_QUERY)?;
981    let enums = three(client, pg::ENUMS_QUERY)?;
982
983    Ok(pg::catalog(
984        &tables,
985        &columns,
986        &views,
987        &indexes,
988        &index_columns,
989        &enums,
990    ))
991}
992
993#[cfg(feature = "postgres-sync")]
994fn repair_dirty_migrations_postgres_sync(
995    client: &mut postgres::Client,
996    set: &Migrations,
997    repair: bool,
998) -> Result<Vec<String>, CliError> {
999    let dirty = client
1000        .query(&set.dirty_names_sql(), &[])
1001        .map_err(|e| CliError::MigrationError(e.to_string()))?
1002        .iter()
1003        .map(|row| row.try_get::<_, String>(0))
1004        .collect::<Result<Vec<_>, _>>()
1005        .map_err(|e| CliError::MigrationError(e.to_string()))?;
1006
1007    let table_ident = set.table_ident_sql();
1008    let to_repair = dirty_migrations_to_repair(set, &dirty, repair)?
1009        .into_iter()
1010        .cloned()
1011        .collect::<Vec<_>>();
1012
1013    let mut repaired = Vec::new();
1014    for migration in &to_repair {
1015        let catalog = introspect_catalog_postgres_sync(client)?;
1016        let plan = drizzle_migrations::repair::plan(
1017            drizzle_types::Dialect::PostgreSQL,
1018            migration,
1019            &catalog,
1020        );
1021        for statement in plan
1022            .into_executable(&table_ident)
1023            .map_err(migrator_cli_error)?
1024        {
1025            client
1026                .execute(statement.as_str(), &[])
1027                .map_err(|e| CliError::MigrationError(format!("Repair statement failed: {e}")))?;
1028        }
1029        client
1030            .execute(&set.record_migration_finished_sql(migration), &[])
1031            .map_err(|e| CliError::MigrationError(e.to_string()))?;
1032        repaired.push(migration.tag().to_string());
1033    }
1034    Ok(repaired)
1035}
1036
1037#[cfg(feature = "tokio-postgres")]
1038async fn introspect_catalog_postgres_async(
1039    client: &tokio_postgres::Client,
1040) -> Result<drizzle_migrations::repair::Catalog, CliError> {
1041    use drizzle_migrations::repair::postgres as pg;
1042
1043    async fn two(
1044        client: &tokio_postgres::Client,
1045        sql: &str,
1046    ) -> Result<Vec<(String, String)>, CliError> {
1047        Ok(client
1048            .query(sql, &[])
1049            .await
1050            .map_err(|e| CliError::MigrationError(e.to_string()))?
1051            .iter()
1052            .map(|row| (row.get::<_, String>(0), row.get::<_, String>(1)))
1053            .collect())
1054    }
1055
1056    async fn three(
1057        client: &tokio_postgres::Client,
1058        sql: &str,
1059    ) -> Result<Vec<(String, String, String)>, CliError> {
1060        Ok(client
1061            .query(sql, &[])
1062            .await
1063            .map_err(|e| CliError::MigrationError(e.to_string()))?
1064            .iter()
1065            .map(|row| {
1066                (
1067                    row.get::<_, String>(0),
1068                    row.get::<_, String>(1),
1069                    row.get::<_, String>(2),
1070                )
1071            })
1072            .collect())
1073    }
1074
1075    let indexes = client
1076        .query(pg::INDEXES_QUERY, &[])
1077        .await
1078        .map_err(|e| CliError::MigrationError(e.to_string()))?
1079        .iter()
1080        .map(|row| {
1081            (
1082                row.get::<_, String>(0),
1083                row.get::<_, String>(1),
1084                row.get::<_, bool>(2),
1085            )
1086        })
1087        .collect::<Vec<_>>();
1088
1089    let tables = two(client, pg::TABLES_QUERY).await?;
1090    let columns = three(client, pg::COLUMNS_QUERY).await?;
1091    let views = two(client, pg::VIEWS_QUERY).await?;
1092    let index_columns = three(client, pg::INDEX_COLUMNS_QUERY).await?;
1093    let enums = three(client, pg::ENUMS_QUERY).await?;
1094
1095    Ok(pg::catalog(
1096        &tables,
1097        &columns,
1098        &views,
1099        &indexes,
1100        &index_columns,
1101        &enums,
1102    ))
1103}
1104
1105#[cfg(feature = "tokio-postgres")]
1106async fn repair_dirty_migrations_postgres_async(
1107    client: &tokio_postgres::Client,
1108    set: &Migrations,
1109    repair: bool,
1110) -> Result<Vec<String>, CliError> {
1111    let dirty = client
1112        .query(&set.dirty_names_sql(), &[])
1113        .await
1114        .map_err(|e| CliError::MigrationError(e.to_string()))?
1115        .iter()
1116        .map(|row| row.try_get::<_, String>(0))
1117        .collect::<Result<Vec<_>, _>>()
1118        .map_err(|e| CliError::MigrationError(e.to_string()))?;
1119
1120    let table_ident = set.table_ident_sql();
1121    let to_repair = dirty_migrations_to_repair(set, &dirty, repair)?
1122        .into_iter()
1123        .cloned()
1124        .collect::<Vec<_>>();
1125
1126    let mut repaired = Vec::new();
1127    for migration in &to_repair {
1128        let catalog = introspect_catalog_postgres_async(client).await?;
1129        let plan = drizzle_migrations::repair::plan(
1130            drizzle_types::Dialect::PostgreSQL,
1131            migration,
1132            &catalog,
1133        );
1134        for statement in plan
1135            .into_executable(&table_ident)
1136            .map_err(migrator_cli_error)?
1137        {
1138            client
1139                .execute(statement.as_str(), &[])
1140                .await
1141                .map_err(|e| CliError::MigrationError(format!("Repair statement failed: {e}")))?;
1142        }
1143        client
1144            .execute(&set.record_migration_finished_sql(migration), &[])
1145            .await
1146            .map_err(|e| CliError::MigrationError(e.to_string()))?;
1147        repaired.push(migration.tag().to_string());
1148    }
1149    Ok(repaired)
1150}
1151
1152/// Reads the tracking table's state without changing it.
1153#[cfg(feature = "rusqlite")]
1154fn read_sqlite_tracking_state(
1155    conn: &rusqlite::Connection,
1156    set: &Migrations,
1157) -> Result<TrackingState, CliError> {
1158    let pragma_sql = format!(
1159        "SELECT name FROM pragma_table_info('{}')",
1160        escape_sql_literal(set.table_name())
1161    );
1162    let mut stmt = conn
1163        .prepare(&pragma_sql)
1164        .map_err(|e| CliError::MigrationError(e.to_string()))?;
1165    let columns = stmt
1166        .query_map([], |row| row.get::<_, String>(0))
1167        .map_err(|e| CliError::MigrationError(e.to_string()))?
1168        .collect::<Result<Vec<_>, _>>()
1169        .map_err(|e| CliError::MigrationError(e.to_string()))?;
1170    if columns.is_empty() {
1171        return Ok(TrackingState::Missing);
1172    }
1173    if columns.iter().any(|column| column == "name") {
1174        return Ok(TrackingState::Current);
1175    }
1176
1177    let mut stmt = conn
1178        .prepare(&format!(
1179            "SELECT id, hash, created_at FROM {} ORDER BY id ASC",
1180            set.table_ident_sql()
1181        ))
1182        .map_err(|e| CliError::MigrationError(e.to_string()))?;
1183    let rows = stmt
1184        .query_map([], |row| {
1185            Ok(drizzle_migrations::AppliedMigrationMetadata {
1186                id: row.get::<_, Option<i64>>(0)?,
1187                hash: row.get::<_, String>(1)?,
1188                created_at: row.get::<_, i64>(2)?,
1189            })
1190        })
1191        .map_err(|e| CliError::MigrationError(e.to_string()))?
1192        .collect::<Result<Vec<_>, _>>()
1193        .map_err(|e| CliError::MigrationError(e.to_string()))?;
1194    Ok(TrackingState::Legacy(rows))
1195}
1196
1197#[cfg(feature = "rusqlite")]
1198fn ensure_sqlite_tracking_table(
1199    conn: &rusqlite::Connection,
1200    set: &Migrations,
1201) -> Result<(), CliError> {
1202    conn.execute(&set.create_table_sql(), [])
1203        .map_err(|e| CliError::MigrationError(format!("Failed to create migrations table: {e}")))?;
1204
1205    let TrackingState::Legacy(applied) = read_sqlite_tracking_state(conn, set)? else {
1206        return Ok(());
1207    };
1208
1209    let matched = drizzle_migrations::match_applied_migration_metadata(set.all(), &applied)
1210        .map_err(|e| CliError::MigrationError(e.to_string()))?;
1211
1212    conn.execute("BEGIN", [])
1213        .map_err(|e| CliError::MigrationError(e.to_string()))?;
1214    let result = (|| -> Result<(), CliError> {
1215        conn.execute(
1216            &format!(
1217                "ALTER TABLE {} ADD COLUMN \"name\" text",
1218                set.table_ident_sql()
1219            ),
1220            [],
1221        )
1222        .map_err(|e| CliError::MigrationError(e.to_string()))?;
1223        conn.execute(
1224            &format!(
1225                "ALTER TABLE {} ADD COLUMN \"applied_at\" TEXT",
1226                set.table_ident_sql()
1227            ),
1228            [],
1229        )
1230        .map_err(|e| CliError::MigrationError(e.to_string()))?;
1231
1232        for row in matched {
1233            conn.execute(&set.backfill_migration_metadata_sql(&row), [])
1234                .map_err(|e| CliError::MigrationError(e.to_string()))?;
1235        }
1236
1237        Ok(())
1238    })();
1239
1240    match result {
1241        Ok(()) => {
1242            conn.execute("COMMIT", [])
1243                .map_err(|e| CliError::MigrationError(e.to_string()))?;
1244            Ok(())
1245        }
1246        Err(err) => {
1247            let _ = conn.execute("ROLLBACK", []);
1248            Err(err)
1249        }
1250    }
1251}
1252
1253/// Reads the tracking table's state without changing it.
1254#[cfg(any(feature = "libsql", feature = "turso"))]
1255async fn read_sqlite_tracking_state_libsql(
1256    conn: &libsql::Connection,
1257    set: &Migrations,
1258) -> Result<TrackingState, CliError> {
1259    let pragma_sql = format!(
1260        "SELECT name FROM pragma_table_info('{}')",
1261        escape_sql_literal(set.table_name())
1262    );
1263    let mut rows = conn
1264        .query(&pragma_sql, ())
1265        .await
1266        .map_err(|e| CliError::MigrationError(e.to_string()))?;
1267    let mut any_column = false;
1268    let mut has_name = false;
1269    while let Some(row) = rows
1270        .next()
1271        .await
1272        .map_err(|e| CliError::MigrationError(e.to_string()))?
1273    {
1274        any_column = true;
1275        let name = row
1276            .get::<String>(0)
1277            .map_err(|e| CliError::MigrationError(e.to_string()))?;
1278        if name == "name" {
1279            has_name = true;
1280        }
1281    }
1282    if !any_column {
1283        return Ok(TrackingState::Missing);
1284    }
1285    if has_name {
1286        return Ok(TrackingState::Current);
1287    }
1288
1289    let mut rows = conn
1290        .query(
1291            &format!(
1292                "SELECT id, hash, created_at FROM {} ORDER BY id ASC",
1293                set.table_ident_sql()
1294            ),
1295            (),
1296        )
1297        .await
1298        .map_err(|e| CliError::MigrationError(e.to_string()))?;
1299    let mut applied = Vec::new();
1300    while let Some(row) = rows
1301        .next()
1302        .await
1303        .map_err(|e| CliError::MigrationError(e.to_string()))?
1304    {
1305        applied.push(drizzle_migrations::AppliedMigrationMetadata {
1306            id: row
1307                .get::<Option<i64>>(0)
1308                .map_err(|e| CliError::MigrationError(e.to_string()))?,
1309            hash: row
1310                .get::<String>(1)
1311                .map_err(|e| CliError::MigrationError(e.to_string()))?,
1312            created_at: row
1313                .get::<i64>(2)
1314                .map_err(|e| CliError::MigrationError(e.to_string()))?,
1315        });
1316    }
1317    Ok(TrackingState::Legacy(applied))
1318}
1319
1320#[cfg(any(feature = "libsql", feature = "turso"))]
1321async fn ensure_sqlite_tracking_table_libsql(
1322    conn: &libsql::Connection,
1323    set: &Migrations,
1324) -> Result<(), CliError> {
1325    conn.execute(&set.create_table_sql(), ())
1326        .await
1327        .map_err(|e| CliError::MigrationError(format!("Failed to create migrations table: {e}")))?;
1328
1329    let TrackingState::Legacy(applied) = read_sqlite_tracking_state_libsql(conn, set).await? else {
1330        return Ok(());
1331    };
1332
1333    let matched = drizzle_migrations::match_applied_migration_metadata(set.all(), &applied)
1334        .map_err(|e| CliError::MigrationError(e.to_string()))?;
1335
1336    let tx = conn
1337        .transaction()
1338        .await
1339        .map_err(|e| CliError::MigrationError(e.to_string()))?;
1340    tx.execute(
1341        &format!(
1342            "ALTER TABLE {} ADD COLUMN \"name\" text",
1343            set.table_ident_sql()
1344        ),
1345        (),
1346    )
1347    .await
1348    .map_err(|e| CliError::MigrationError(e.to_string()))?;
1349    tx.execute(
1350        &format!(
1351            "ALTER TABLE {} ADD COLUMN \"applied_at\" TEXT",
1352            set.table_ident_sql()
1353        ),
1354        (),
1355    )
1356    .await
1357    .map_err(|e| CliError::MigrationError(e.to_string()))?;
1358
1359    for row in matched {
1360        tx.execute(&set.backfill_migration_metadata_sql(&row), ())
1361            .await
1362            .map_err(|e| CliError::MigrationError(e.to_string()))?;
1363    }
1364
1365    tx.commit()
1366        .await
1367        .map_err(|e| CliError::MigrationError(e.to_string()))?;
1368    Ok(())
1369}
1370
1371/// Reads the tracking table's state without changing it.
1372#[cfg(feature = "postgres-sync")]
1373fn read_postgres_tracking_state_sync(
1374    client: &mut postgres::Client,
1375    set: &Migrations,
1376) -> Result<TrackingState, CliError> {
1377    let schema = set.schema_name().unwrap_or("public");
1378    let rows = client
1379        .query(
1380            "SELECT column_name FROM information_schema.columns WHERE table_schema = $1 AND table_name = $2",
1381            &[&schema, &set.table_name()],
1382        )
1383        .map_err(|e| CliError::MigrationError(e.to_string()))?;
1384    let columns = rows
1385        .iter()
1386        .map(|row| row.try_get::<_, String>(0))
1387        .collect::<Result<Vec<_>, postgres::Error>>()
1388        .map_err(|e| CliError::MigrationError(e.to_string()))?;
1389    if columns.is_empty() {
1390        return Ok(TrackingState::Missing);
1391    }
1392    if columns.iter().any(|column| column == "name") {
1393        return Ok(TrackingState::Current);
1394    }
1395
1396    let rows = client
1397        .query(
1398            &format!(
1399                "SELECT id::bigint, hash, created_at FROM {} ORDER BY id ASC",
1400                set.table_ident_sql()
1401            ),
1402            &[],
1403        )
1404        .map_err(|e| CliError::MigrationError(e.to_string()))?;
1405    let applied = rows
1406        .iter()
1407        .map(|row| {
1408            Ok(drizzle_migrations::AppliedMigrationMetadata {
1409                id: row.try_get::<_, Option<i64>>(0)?,
1410                hash: row.try_get::<_, String>(1)?,
1411                created_at: row.try_get::<_, i64>(2)?,
1412            })
1413        })
1414        .collect::<Result<Vec<_>, postgres::Error>>()
1415        .map_err(|e| CliError::MigrationError(e.to_string()))?;
1416    Ok(TrackingState::Legacy(applied))
1417}
1418
1419#[cfg(feature = "postgres-sync")]
1420fn ensure_postgres_tracking_table_sync(
1421    client: &mut postgres::Client,
1422    set: &Migrations,
1423) -> Result<(), CliError> {
1424    client
1425        .execute(&set.create_table_sql(), &[])
1426        .map_err(|e| CliError::MigrationError(format!("Failed to create migrations table: {e}")))?;
1427
1428    let TrackingState::Legacy(applied) = read_postgres_tracking_state_sync(client, set)? else {
1429        return Ok(());
1430    };
1431
1432    let matched = drizzle_migrations::match_applied_migration_metadata(set.all(), &applied)
1433        .map_err(|e| CliError::MigrationError(e.to_string()))?;
1434
1435    client
1436        .execute(
1437            &format!(
1438                "ALTER TABLE {} ADD COLUMN \"name\" TEXT",
1439                set.table_ident_sql()
1440            ),
1441            &[],
1442        )
1443        .map_err(|e| CliError::MigrationError(e.to_string()))?;
1444    client
1445        .execute(
1446            &format!(
1447                "ALTER TABLE {} ADD COLUMN \"applied_at\" TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP",
1448                set.table_ident_sql()
1449            ),
1450            &[],
1451        )
1452        .map_err(|e| CliError::MigrationError(e.to_string()))?;
1453
1454    for row in matched {
1455        client
1456            .execute(&set.backfill_migration_metadata_sql(&row), &[])
1457            .map_err(|e| CliError::MigrationError(e.to_string()))?;
1458    }
1459
1460    Ok(())
1461}
1462
1463/// Reads the tracking table's state without changing it.
1464#[cfg(feature = "tokio-postgres")]
1465async fn read_postgres_tracking_state_async(
1466    client: &tokio_postgres::Client,
1467    set: &Migrations,
1468) -> Result<TrackingState, CliError> {
1469    let schema = set.schema_name().unwrap_or("public");
1470    let rows = client
1471        .query(
1472            "SELECT column_name FROM information_schema.columns WHERE table_schema = $1 AND table_name = $2",
1473            &[&schema, &set.table_name()],
1474        )
1475        .await
1476        .map_err(|e| CliError::MigrationError(e.to_string()))?;
1477    let columns = rows
1478        .iter()
1479        .map(|row| row.try_get::<_, String>(0))
1480        .collect::<Result<Vec<_>, tokio_postgres::Error>>()
1481        .map_err(|e| CliError::MigrationError(e.to_string()))?;
1482    if columns.is_empty() {
1483        return Ok(TrackingState::Missing);
1484    }
1485    if columns.iter().any(|column| column == "name") {
1486        return Ok(TrackingState::Current);
1487    }
1488
1489    let rows = client
1490        .query(
1491            &format!(
1492                "SELECT id::bigint, hash, created_at FROM {} ORDER BY id ASC",
1493                set.table_ident_sql()
1494            ),
1495            &[],
1496        )
1497        .await
1498        .map_err(|e| CliError::MigrationError(e.to_string()))?;
1499    let applied = rows
1500        .iter()
1501        .map(|row| {
1502            Ok(drizzle_migrations::AppliedMigrationMetadata {
1503                id: row.try_get::<_, Option<i64>>(0)?,
1504                hash: row.try_get::<_, String>(1)?,
1505                created_at: row.try_get::<_, i64>(2)?,
1506            })
1507        })
1508        .collect::<Result<Vec<_>, tokio_postgres::Error>>()
1509        .map_err(|e| CliError::MigrationError(e.to_string()))?;
1510    Ok(TrackingState::Legacy(applied))
1511}
1512
1513#[cfg(feature = "tokio-postgres")]
1514async fn ensure_postgres_tracking_table_async(
1515    client: &tokio_postgres::Client,
1516    set: &Migrations,
1517) -> Result<(), CliError> {
1518    client
1519        .execute(&set.create_table_sql(), &[])
1520        .await
1521        .map_err(|e| CliError::MigrationError(format!("Failed to create migrations table: {e}")))?;
1522
1523    let TrackingState::Legacy(applied) = read_postgres_tracking_state_async(client, set).await?
1524    else {
1525        return Ok(());
1526    };
1527
1528    let matched = drizzle_migrations::match_applied_migration_metadata(set.all(), &applied)
1529        .map_err(|e| CliError::MigrationError(e.to_string()))?;
1530
1531    client
1532        .execute(
1533            &format!(
1534                "ALTER TABLE {} ADD COLUMN \"name\" TEXT",
1535                set.table_ident_sql()
1536            ),
1537            &[],
1538        )
1539        .await
1540        .map_err(|e| CliError::MigrationError(e.to_string()))?;
1541    client
1542        .execute(
1543            &format!(
1544                "ALTER TABLE {} ADD COLUMN \"applied_at\" TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP",
1545                set.table_ident_sql()
1546            ),
1547            &[],
1548        )
1549        .await
1550        .map_err(|e| CliError::MigrationError(e.to_string()))?;
1551
1552    for row in matched {
1553        client
1554            .execute(&set.backfill_migration_metadata_sql(&row), &[])
1555            .await
1556            .map_err(|e| CliError::MigrationError(e.to_string()))?;
1557    }
1558
1559    Ok(())
1560}
1561
1562fn is_destructive_statement(sql: &str) -> bool {
1563    let s = sql.trim().to_uppercase();
1564    s.contains("DROP TABLE")
1565        || s.contains("DROP COLUMN")
1566        || s.contains("DROP INDEX")
1567        || s.contains("DROP VIEW")
1568        || s.contains("DROP MATERIALIZED VIEW")
1569        || s.contains("DROP TYPE")
1570        || s.contains("DROP SCHEMA")
1571        || s.contains("DROP SEQUENCE")
1572        || s.contains("DROP ROLE")
1573        || s.contains("DROP POLICY")
1574        || s.contains("TRUNCATE")
1575        || (s.contains("ALTER TABLE") && s.contains(" DROP "))
1576}
1577
1578fn push_requires_confirmation(
1579    dialect: Dialect,
1580    statements: &[String],
1581    warnings: &[String],
1582) -> bool {
1583    statements
1584        .iter()
1585        .any(|statement| is_destructive_statement(statement))
1586        // MySQL's ALTER TABLE ... MODIFY COLUMN syntax does not say DROP even
1587        // when it can truncate, reject, or recode stored values. Every warning
1588        // produced by the typed MySQL diff represents one of those risky
1589        // structural operations, so it belongs behind the same confirmation
1590        // boundary as explicit DROP/TRUNCATE statements.
1591        || (dialect == Dialect::Mysql && !warnings.is_empty())
1592}
1593
1594#[cfg(any(feature = "postgres-sync", feature = "tokio-postgres"))]
1595fn is_postgres_concurrent_index_statement(sql: &str) -> bool {
1596    drizzle_migrations::is_postgres_concurrent_index_statement(sql)
1597}
1598
1599#[cfg(any(feature = "postgres-sync", feature = "tokio-postgres"))]
1600fn has_postgres_concurrent_index(statements: &[String]) -> bool {
1601    statements
1602        .iter()
1603        .any(|stmt| is_postgres_concurrent_index_statement(stmt))
1604}
1605
1606fn confirm_destructive() -> Result<bool, CliError> {
1607    use std::io::{self, IsTerminal, Write};
1608
1609    if !io::stdin().is_terminal() {
1610        return Err(CliError::Other(
1611            "Refusing to apply potentially destructive changes in non-interactive mode. Use --explain or --force."
1612                .into(),
1613        ));
1614    }
1615
1616    println!(
1617        "{}",
1618        output::warning("Potentially destructive changes detected (DROP/TRUNCATE/etc).")
1619    );
1620    print!("Apply anyway? [y/N]: ");
1621    io::stdout()
1622        .flush()
1623        .map_err(|e| CliError::IoError(e.to_string()))?;
1624
1625    let mut line = String::new();
1626    io::stdin()
1627        .read_line(&mut line)
1628        .map_err(|e| CliError::IoError(e.to_string()))?;
1629    let ans = line.trim().to_ascii_lowercase();
1630    Ok(ans == "y" || ans == "yes")
1631}
1632
1633fn generate_push_sql(
1634    current: &Snapshot,
1635    desired: &Snapshot,
1636    _breakpoints: bool,
1637    options: &drizzle_migrations::DiffOptions,
1638) -> Result<(Vec<String>, Vec<String>), CliError> {
1639    let plan = drizzle_migrations::diff_with(current, desired, options)
1640        .map_err(|error| CliError::MigrationError(error.to_string()))?;
1641    Ok((plan.statements, plan.warnings))
1642}
1643
1644fn execute_statements(
1645    connection: &ResolvedConnection,
1646    statements: &[String],
1647) -> Result<(), CliError> {
1648    // In some feature combinations (no drivers), the match arms that would use `statements`
1649    // are compiled out. Touch it to avoid unused-parameter warnings.
1650    let _ = statements;
1651
1652    match &connection.credentials {
1653        #[cfg(feature = "rusqlite")]
1654        Credentials::Sqlite { path } if connection.driver == Driver::Rusqlite => {
1655            execute_sqlite_statements(path, statements)
1656        }
1657
1658        #[cfg(feature = "rusqlite")]
1659        Credentials::Sqlite { .. } => Err(connection_driver_mismatch(connection)),
1660
1661        #[cfg(not(feature = "rusqlite"))]
1662        Credentials::Sqlite { .. } => Err(CliError::MissingDriver {
1663            dialect: "SQLite",
1664            feature: "rusqlite",
1665        }),
1666
1667        #[cfg(any(feature = "libsql", feature = "turso"))]
1668        Credentials::Turso { url, auth_token } => {
1669            if connection.driver == Driver::Libsql {
1670                #[cfg(feature = "libsql")]
1671                {
1672                    execute_libsql_local_statements(url, statements)
1673                }
1674                #[cfg(not(feature = "libsql"))]
1675                {
1676                    Err(CliError::MissingDriver {
1677                        dialect: "LibSQL (local)",
1678                        feature: "libsql",
1679                    })
1680                }
1681            } else if connection.driver == Driver::Turso {
1682                #[cfg(feature = "turso")]
1683                {
1684                    execute_turso_statements(url, auth_token.as_deref(), statements)
1685                }
1686                #[cfg(not(feature = "turso"))]
1687                {
1688                    let _ = auth_token;
1689                    Err(CliError::MissingDriver {
1690                        dialect: "Turso (remote)",
1691                        feature: "turso",
1692                    })
1693                }
1694            } else {
1695                Err(connection_driver_mismatch(connection))
1696            }
1697        }
1698
1699        #[cfg(all(not(feature = "turso"), not(feature = "libsql")))]
1700        Credentials::Turso { .. } => Err(CliError::MissingDriver {
1701            dialect: "Turso",
1702            feature: "turso or libsql",
1703        }),
1704
1705        Credentials::Postgres(creds) => {
1706            let _ = creds;
1707            match connection.driver {
1708                Driver::PostgresSync => {
1709                    #[cfg(feature = "postgres-sync")]
1710                    {
1711                        execute_postgres_sync_statements(creds, statements)
1712                    }
1713                    #[cfg(not(feature = "postgres-sync"))]
1714                    {
1715                        Err(CliError::MissingDriver {
1716                            dialect: "PostgreSQL",
1717                            feature: "postgres-sync",
1718                        })
1719                    }
1720                }
1721                Driver::TokioPostgres => {
1722                    #[cfg(feature = "tokio-postgres")]
1723                    {
1724                        execute_postgres_async_statements(creds, statements)
1725                    }
1726                    #[cfg(not(feature = "tokio-postgres"))]
1727                    {
1728                        Err(CliError::MissingDriver {
1729                            dialect: "PostgreSQL",
1730                            feature: "tokio-postgres",
1731                        })
1732                    }
1733                }
1734                _ => Err(connection_driver_mismatch(connection)),
1735            }
1736        }
1737
1738        Credentials::MySQL(creds) => {
1739            mysql::execute_statements(connection.driver, creds, statements)
1740        }
1741
1742        #[cfg(feature = "d1-http")]
1743        Credentials::D1 {
1744            account_id,
1745            database_id,
1746            token,
1747        } if connection.driver == Driver::D1Http => {
1748            d1_http::execute_statements(account_id, database_id, token, statements)
1749        }
1750
1751        #[cfg(feature = "d1-http")]
1752        Credentials::D1 { .. } => Err(connection_driver_mismatch(connection)),
1753
1754        #[cfg(not(feature = "d1-http"))]
1755        Credentials::D1 { .. } => Err(CliError::MissingDriver {
1756            dialect: "Cloudflare D1 (HTTP)",
1757            feature: "d1-http",
1758        }),
1759
1760        Credentials::AwsDataApi { .. } => Err(CliError::UnsupportedForDriver {
1761            operation: "Direct SQL execution against AWS Data API",
1762            driver: "aws-data-api",
1763            hint: "Use `aws rds-data execute-statement --sql=\"...\"` with the matching \
1764                   --resource-arn / --secret-arn / --database, or connect directly via \
1765                   tokio-postgres.",
1766        }),
1767    }
1768}
1769
1770/// Check if a Turso URL is a local libsql database
1771#[allow(dead_code)]
1772fn is_local_libsql(url: &str) -> bool {
1773    url.starts_with("file:")
1774        || url.starts_with("./")
1775        || url.starts_with('/')
1776        || !url.contains("://")
1777}
1778
1779// ============================================================================
1780// SQLite (rusqlite)
1781// ============================================================================
1782
1783#[cfg(feature = "rusqlite")]
1784fn execute_sqlite_statements(path: &str, statements: &[String]) -> Result<(), CliError> {
1785    let conn = rusqlite::Connection::open(path).map_err(|e| {
1786        CliError::ConnectionError(format!("Failed to open SQLite database '{path}': {e}"))
1787    })?;
1788
1789    execute_sqlite_statements_on_connection(&conn, statements)
1790}
1791
1792#[cfg(feature = "rusqlite")]
1793fn execute_sqlite_statements_on_connection(
1794    conn: &rusqlite::Connection,
1795    statements: &[String],
1796) -> Result<(), CliError> {
1797    let operation =
1798        drizzle_migrations::Migration::with_hash("sqlite_push", "", 0, statements.to_vec());
1799    let execution = operation
1800        .sqlite_execution()
1801        .map_err(sqlite_execution_cli_error)?;
1802    let foreign_keys_were_enabled = sqlite_foreign_keys_enabled_cli(conn)?;
1803    let suspend_foreign_keys = execution.suspends_foreign_keys() && foreign_keys_were_enabled;
1804    if suspend_foreign_keys && let Err(error) = set_sqlite_foreign_keys_cli(conn, false) {
1805        let restore = set_sqlite_foreign_keys_cli(conn, true);
1806        return finish_sqlite_foreign_key_scope(Err(error), restore);
1807    }
1808
1809    let result = (|| -> Result<(), CliError> {
1810        conn.execute("BEGIN IMMEDIATE", [])
1811            .map_err(|error| CliError::MigrationError(error.to_string()))?;
1812        for statement in execution
1813            .statements()
1814            .filter(|statement| !statement.trim().is_empty())
1815        {
1816            conn.execute(statement, []).map_err(|error| {
1817                CliError::MigrationError(format!("Statement failed: {error}\n{statement}"))
1818            })?;
1819        }
1820        if execution.suspends_foreign_keys() {
1821            verify_sqlite_foreign_keys_cli(conn)?;
1822        }
1823        conn.execute("COMMIT", [])
1824            .map_err(|error| CliError::MigrationError(error.to_string()))?;
1825        Ok(())
1826    })();
1827    let result = match result {
1828        Ok(()) => Ok(()),
1829        Err(error) => {
1830            let _ = conn.execute("ROLLBACK", []);
1831            Err(error)
1832        }
1833    };
1834    let restore = if suspend_foreign_keys {
1835        set_sqlite_foreign_keys_cli(conn, true)
1836    } else {
1837        Ok(())
1838    };
1839    finish_sqlite_foreign_key_scope(result, restore)
1840}
1841
1842#[cfg(feature = "rusqlite")]
1843fn run_sqlite_migrations(
1844    set: &Migrations,
1845    path: &str,
1846    repair: bool,
1847) -> Result<MigrationResult, CliError> {
1848    let conn = rusqlite::Connection::open(path).map_err(|e| {
1849        CliError::ConnectionError(format!("Failed to open SQLite database '{path}': {e}"))
1850    })?;
1851
1852    run_sqlite_migrations_on_connection(set, &conn, repair)
1853}
1854
1855#[cfg(feature = "rusqlite")]
1856fn run_sqlite_migrations_on_connection(
1857    set: &Migrations,
1858    conn: &rusqlite::Connection,
1859    repair: bool,
1860) -> Result<MigrationResult, CliError> {
1861    ensure_sqlite_tracking_table(conn, set)?;
1862    conn.busy_timeout(std::time::Duration::from_secs(30))
1863        .map_err(|e| CliError::MigrationError(e.to_string()))?;
1864    let applied_before_transaction = query_applied_names_sqlite(conn, set)?;
1865    let suspends_foreign_keys = set
1866        .pending(&applied_before_transaction)
1867        .map(|migration| migration.sqlite_execution())
1868        .collect::<Result<Vec<_>, _>>()
1869        .map_err(sqlite_execution_cli_error)?
1870        .iter()
1871        .any(|execution| execution.suspends_foreign_keys());
1872    let foreign_keys_were_enabled = sqlite_foreign_keys_enabled_cli(conn)?;
1873    let suspend_foreign_keys = suspends_foreign_keys && foreign_keys_were_enabled;
1874    if suspend_foreign_keys && let Err(error) = set_sqlite_foreign_keys_cli(conn, false) {
1875        let restore = set_sqlite_foreign_keys_cli(conn, true);
1876        return finish_sqlite_foreign_key_scope(Err(error), restore);
1877    }
1878
1879    let result = (|| -> Result<MigrationResult, CliError> {
1880        conn.execute("BEGIN IMMEDIATE", [])
1881            .map_err(|e| CliError::MigrationError(e.to_string()))?;
1882        let repaired = repair_dirty_migrations_sqlite(conn, set, repair)?;
1883        let applied_names = query_applied_names_sqlite(conn, set)?;
1884        let pending = set
1885            .pending(&applied_names)
1886            .map(|migration| {
1887                migration
1888                    .sqlite_execution()
1889                    .map(|execution| (migration, execution))
1890            })
1891            .collect::<Result<Vec<_>, _>>()
1892            .map_err(sqlite_execution_cli_error)?;
1893
1894        let mut applied = Vec::with_capacity(pending.len());
1895        for (migration, execution) in pending {
1896            for statement in execution
1897                .statements()
1898                .filter(|statement| !statement.trim().is_empty())
1899            {
1900                conn.execute(statement, []).map_err(|error| {
1901                    CliError::MigrationError(format!(
1902                        "Migration '{}' failed: {error}",
1903                        migration.hash()
1904                    ))
1905                })?;
1906            }
1907            conn.execute(&set.record_migration_sql(migration), [])
1908                .map_err(|e| CliError::MigrationError(e.to_string()))?;
1909            applied.push(migration.hash().to_string());
1910        }
1911
1912        Ok(MigrationResult {
1913            applied_count: applied.len(),
1914            applied_migrations: applied,
1915            repaired_migrations: repaired,
1916        })
1917    })();
1918
1919    let result = match result {
1920        Ok(outcome) => match suspends_foreign_keys
1921            .then(|| verify_sqlite_foreign_keys_cli(conn))
1922            .transpose()
1923        {
1924            Ok(_) => conn
1925                .execute("COMMIT", [])
1926                .map(|_| outcome)
1927                .map_err(|e| CliError::MigrationError(e.to_string())),
1928            Err(error) => {
1929                let _ = conn.execute("ROLLBACK", []);
1930                Err(error)
1931            }
1932        },
1933        Err(error) => {
1934            let _ = conn.execute("ROLLBACK", []);
1935            Err(error)
1936        }
1937    };
1938    let restore = if suspend_foreign_keys {
1939        set_sqlite_foreign_keys_cli(conn, true)
1940    } else {
1941        Ok(())
1942    };
1943    finish_sqlite_foreign_key_scope(result, restore)
1944}
1945
1946#[cfg(feature = "rusqlite")]
1947fn sqlite_foreign_keys_enabled_cli(conn: &rusqlite::Connection) -> Result<bool, CliError> {
1948    conn.query_row("PRAGMA foreign_keys", [], |row| row.get::<_, i64>(0))
1949        .map(|enabled| enabled != 0)
1950        .map_err(|error| CliError::MigrationError(error.to_string()))
1951}
1952
1953#[cfg(feature = "rusqlite")]
1954fn set_sqlite_foreign_keys_cli(conn: &rusqlite::Connection, enabled: bool) -> Result<(), CliError> {
1955    conn.execute_batch(if enabled {
1956        "PRAGMA foreign_keys=ON"
1957    } else {
1958        "PRAGMA foreign_keys=OFF"
1959    })
1960    .map_err(|error| CliError::MigrationError(error.to_string()))?;
1961    if sqlite_foreign_keys_enabled_cli(conn)? != enabled {
1962        return Err(CliError::MigrationError(format!(
1963            "SQLite refused to set foreign_keys={}",
1964            if enabled { "ON" } else { "OFF" }
1965        )));
1966    }
1967    Ok(())
1968}
1969
1970#[cfg(feature = "rusqlite")]
1971fn verify_sqlite_foreign_keys_cli(conn: &rusqlite::Connection) -> Result<(), CliError> {
1972    let mut statement = conn
1973        .prepare("PRAGMA foreign_key_check")
1974        .map_err(|error| CliError::MigrationError(error.to_string()))?;
1975    let mut rows = statement
1976        .query([])
1977        .map_err(|error| CliError::MigrationError(error.to_string()))?;
1978    if rows
1979        .next()
1980        .map_err(|error| CliError::MigrationError(error.to_string()))?
1981        .is_some()
1982    {
1983        return Err(CliError::MigrationError(
1984            "SQLite foreign_key_check failed after migration rebuild".to_string(),
1985        ));
1986    }
1987    Ok(())
1988}
1989
1990#[cfg(feature = "rusqlite")]
1991fn inspect_sqlite_migrations(set: &Migrations, path: &str) -> Result<MigrationPlan, CliError> {
1992    if !sqlite_database_exists(path) {
1993        return build_migration_plan(set, &[]);
1994    }
1995    let conn = rusqlite::Connection::open(path).map_err(|e| {
1996        CliError::ConnectionError(format!("Failed to open SQLite database '{path}': {e}"))
1997    })?;
1998
1999    let applied = match read_sqlite_tracking_state(&conn, set)? {
2000        TrackingState::Missing => Vec::new(),
2001        TrackingState::Legacy(rows) => legacy_applied_records(set, &rows)?,
2002        TrackingState::Current => query_applied_records_sqlite(&conn, set)?,
2003    };
2004    build_migration_plan(set, &applied)
2005}
2006
2007#[cfg(feature = "rusqlite")]
2008fn is_sqlite_missing_table(error: &rusqlite::Error) -> bool {
2009    matches!(
2010        error,
2011        rusqlite::Error::SqliteFailure(_, Some(message)) if message.contains("no such table")
2012    )
2013}
2014
2015#[cfg(feature = "rusqlite")]
2016fn query_applied_records_sqlite(
2017    conn: &rusqlite::Connection,
2018    set: &Migrations,
2019) -> Result<Vec<AppliedMigrationRecord>, CliError> {
2020    let sql = set.applied_records_sql();
2021    let mut stmt = match conn.prepare(&sql) {
2022        Ok(stmt) => stmt,
2023        Err(error) if is_sqlite_missing_table(&error) => return Ok(vec![]),
2024        Err(error) => return Err(CliError::MigrationError(error.to_string())),
2025    };
2026
2027    let mut applied = Vec::new();
2028    let rows = stmt
2029        .query_map([], |row| {
2030            Ok((
2031                row.get::<_, String>(0)?,
2032                row.get::<_, String>(1)?,
2033                row.get::<_, i64>(2)? != 0,
2034            ))
2035        })
2036        .map_err(|e| CliError::MigrationError(e.to_string()))?;
2037
2038    for row in rows {
2039        let (hash, name, dirty) = row.map_err(|e| CliError::MigrationError(e.to_string()))?;
2040        applied.push(AppliedMigrationRecord { hash, name, dirty });
2041    }
2042
2043    Ok(applied)
2044}
2045
2046#[cfg(feature = "rusqlite")]
2047fn query_applied_names_sqlite(
2048    conn: &rusqlite::Connection,
2049    set: &Migrations,
2050) -> Result<Vec<String>, CliError> {
2051    let mut stmt = match conn.prepare(&set.applied_names_sql()) {
2052        Ok(stmt) => stmt,
2053        Err(error) if is_sqlite_missing_table(&error) => return Ok(vec![]),
2054        Err(error) => return Err(CliError::MigrationError(error.to_string())),
2055    };
2056
2057    let names = stmt
2058        .query_map([], |row| row.get::<_, String>(0))
2059        .map_err(|e| CliError::MigrationError(e.to_string()))?
2060        .collect::<Result<Vec<_>, _>>()
2061        .map_err(|error| CliError::MigrationError(error.to_string()))?;
2062
2063    Ok(names)
2064}
2065
2066// ============================================================================
2067// PostgreSQL (postgres - sync)
2068// ============================================================================
2069
2070#[cfg(any(feature = "postgres-sync", feature = "tokio-postgres"))]
2071fn postgres_tls_connector(
2072    mode: crate::config::PostgresSslMode,
2073) -> Result<postgres_native_tls::MakeTlsConnector, CliError> {
2074    let mut builder = native_tls::TlsConnector::builder();
2075    if mode == crate::config::PostgresSslMode::VerifyCa {
2076        builder.danger_accept_invalid_hostnames(true);
2077    }
2078    let connector = builder
2079        .build()
2080        .map_err(|error| CliError::ConnectionError(error.to_string()))?;
2081    Ok(postgres_native_tls::MakeTlsConnector::new(connector))
2082}
2083
2084#[cfg(feature = "postgres-sync")]
2085fn connect_postgres_sync(creds: &PostgresCreds) -> Result<postgres::Client, CliError> {
2086    let connection = creds
2087        .connection_config()
2088        .map_err(CliError::ConnectionError)?;
2089    let config = match creds {
2090        PostgresCreds::Url(url) => url
2091            .parse::<postgres::Config>()
2092            .map_err(|error| CliError::ConnectionError(error.to_string()))?,
2093        PostgresCreds::Host {
2094            host,
2095            port,
2096            user,
2097            password,
2098            database,
2099            ..
2100        } => {
2101            let mut config = postgres::Config::new();
2102            config
2103                .host(host.as_ref())
2104                .port(*port)
2105                .dbname(database.as_ref());
2106            if let Some(user) = user {
2107                config.user(user.as_ref());
2108            }
2109            if let Some(password) = password {
2110                config.password(password.as_bytes());
2111            }
2112            config.ssl_mode(connection.config.get_ssl_mode());
2113            config
2114        }
2115    };
2116    if connection.ssl == crate::config::PostgresSslMode::Disable {
2117        config
2118            .connect(postgres::NoTls)
2119            .map_err(|error| CliError::ConnectionError(error.to_string()))
2120    } else {
2121        let connector = postgres_tls_connector(connection.ssl)?;
2122        config
2123            .connect(connector)
2124            .map_err(|error| CliError::ConnectionError(error.to_string()))
2125    }
2126}
2127
2128#[cfg(feature = "tokio-postgres")]
2129async fn connect_postgres_async(creds: &PostgresCreds) -> Result<tokio_postgres::Client, CliError> {
2130    let connection = creds
2131        .connection_config()
2132        .map_err(CliError::ConnectionError)?;
2133    if connection.ssl == crate::config::PostgresSslMode::Disable {
2134        let (client, driver) = connection
2135            .config
2136            .connect(tokio_postgres::NoTls)
2137            .await
2138            .map_err(|error| CliError::ConnectionError(error.to_string()))?;
2139        tokio::spawn(async move {
2140            if let Err(error) = driver.await {
2141                eprintln!("PostgreSQL connection error: {error}");
2142            }
2143        });
2144        Ok(client)
2145    } else {
2146        let connector = postgres_tls_connector(connection.ssl)?;
2147        let (client, driver) = connection
2148            .config
2149            .connect(connector)
2150            .await
2151            .map_err(|error| CliError::ConnectionError(error.to_string()))?;
2152        tokio::spawn(async move {
2153            if let Err(error) = driver.await {
2154                eprintln!("PostgreSQL connection error: {error}");
2155            }
2156        });
2157        Ok(client)
2158    }
2159}
2160
2161#[cfg(feature = "postgres-sync")]
2162fn execute_postgres_sync_statements(
2163    creds: &PostgresCreds,
2164    statements: &[String],
2165) -> Result<(), CliError> {
2166    let mut client = connect_postgres_sync(creds)?;
2167
2168    if has_postgres_concurrent_index(statements) {
2169        // CREATE/DROP INDEX CONCURRENTLY cannot run inside a transaction block.
2170        for stmt in statements {
2171            let s = stmt.trim();
2172            if s.is_empty() {
2173                continue;
2174            }
2175            client
2176                .execute(s, &[])
2177                .map_err(|e| CliError::MigrationError(format!("Statement failed: {e}\n{s}")))?;
2178        }
2179    } else {
2180        let mut tx = client
2181            .transaction()
2182            .map_err(|e| CliError::MigrationError(e.to_string()))?;
2183
2184        for stmt in statements {
2185            let s = stmt.trim();
2186            if s.is_empty() {
2187                continue;
2188            }
2189            tx.execute(s, &[])
2190                .map_err(|e| CliError::MigrationError(format!("Statement failed: {e}\n{s}")))?;
2191        }
2192
2193        tx.commit()
2194            .map_err(|e| CliError::MigrationError(e.to_string()))?;
2195    }
2196
2197    Ok(())
2198}
2199
2200#[cfg(feature = "postgres-sync")]
2201fn run_postgres_sync_migrations(
2202    set: &Migrations,
2203    creds: &PostgresCreds,
2204    repair: bool,
2205) -> Result<MigrationResult, CliError> {
2206    let mut client = connect_postgres_sync(creds)?;
2207
2208    // Create schema if needed
2209    if let Some(schema_sql) = set.create_schema_sql() {
2210        client
2211            .execute(&schema_sql, &[])
2212            .map_err(|e| CliError::MigrationError(e.to_string()))?;
2213    }
2214
2215    let lock_key = set.postgres_advisory_lock_key();
2216    client
2217        .query_one("SELECT pg_advisory_lock($1)", &[&lock_key])
2218        .map_err(|error| CliError::MigrationError(error.to_string()))?;
2219    let result = run_postgres_sync_migrations_locked(&mut client, set, repair);
2220    let unlock = client
2221        .query_one("SELECT pg_advisory_unlock($1)", &[&lock_key])
2222        .map_err(|error| CliError::MigrationError(error.to_string()));
2223    match (result, unlock) {
2224        (Ok(result), Ok(_)) => Ok(result),
2225        (Err(error), _) => Err(error),
2226        (Ok(_), Err(error)) => Err(error),
2227    }
2228}
2229
2230#[cfg(feature = "postgres-sync")]
2231fn run_postgres_sync_migrations_locked(
2232    client: &mut postgres::Client,
2233    set: &Migrations,
2234    repair: bool,
2235) -> Result<MigrationResult, CliError> {
2236    ensure_postgres_tracking_table_sync(client, set)?;
2237    let repaired = repair_dirty_migrations_postgres_sync(client, set, repair)?;
2238    let rows = client
2239        .query(&set.applied_names_sql(), &[])
2240        .map_err(|error| CliError::MigrationError(error.to_string()))?;
2241    let applied_names = rows
2242        .iter()
2243        .map(|row| row.try_get(0))
2244        .collect::<Result<Vec<String>, _>>()
2245        .map_err(|error| CliError::MigrationError(error.to_string()))?;
2246    let pending: Vec<_> = set.pending(&applied_names).collect();
2247    if pending.is_empty() {
2248        return Ok(MigrationResult {
2249            applied_count: 0,
2250            applied_migrations: vec![],
2251            repaired_migrations: repaired,
2252        });
2253    }
2254
2255    let mut applied = Vec::new();
2256    if set.has_postgres_concurrent_index() {
2257        // CREATE/DROP INDEX CONCURRENTLY cannot run in a transaction, so this
2258        // path uses two-phase tracking: mark the migration dirty, run the
2259        // statements autocommit, then stamp applied_at. A crash in between
2260        // leaves a recoverable dirty row instead of an untracked partial
2261        // schema.
2262        for migration in &pending {
2263            client
2264                .execute(&set.record_migration_started_sql(migration), &[])
2265                .map_err(|error| CliError::MigrationError(error.to_string()))?;
2266
2267            let mut executed = 0usize;
2268            for statement in migration.statements() {
2269                if statement.trim().is_empty() {
2270                    continue;
2271                }
2272                if let Err(error) = client.execute(statement, &[]) {
2273                    // Nothing applied yet - drop the marker instead of
2274                    // demanding a pointless repair.
2275                    if executed == 0 {
2276                        let _ = client.execute(&set.clear_migration_started_sql(migration), &[]);
2277                    }
2278                    return Err(CliError::MigrationError(format!(
2279                        "Migration '{}' failed: {error}",
2280                        migration.hash()
2281                    )));
2282                }
2283                executed += 1;
2284            }
2285
2286            client
2287                .execute(&set.record_migration_finished_sql(migration), &[])
2288                .map_err(|error| CliError::MigrationError(error.to_string()))?;
2289            applied.push(migration.hash().to_string());
2290        }
2291    } else {
2292        let mut transaction = client
2293            .transaction()
2294            .map_err(|error| CliError::MigrationError(error.to_string()))?;
2295        for migration in &pending {
2296            for statement in migration.statements() {
2297                if !statement.trim().is_empty() {
2298                    transaction.execute(statement, &[]).map_err(|error| {
2299                        CliError::MigrationError(format!(
2300                            "Migration '{}' failed: {error}",
2301                            migration.hash()
2302                        ))
2303                    })?;
2304                }
2305            }
2306            transaction
2307                .execute(&set.record_migration_sql(migration), &[])
2308                .map_err(|error| CliError::MigrationError(error.to_string()))?;
2309            applied.push(migration.hash().to_string());
2310        }
2311        transaction
2312            .commit()
2313            .map_err(|error| CliError::MigrationError(error.to_string()))?;
2314    }
2315
2316    Ok(MigrationResult {
2317        applied_count: applied.len(),
2318        applied_migrations: applied,
2319        repaired_migrations: repaired,
2320    })
2321}
2322
2323#[cfg(feature = "postgres-sync")]
2324fn inspect_postgres_sync_migrations(
2325    set: &Migrations,
2326    creds: &PostgresCreds,
2327) -> Result<MigrationPlan, CliError> {
2328    let mut client = connect_postgres_sync(creds)?;
2329
2330    // Read-only: no CREATE SCHEMA, no tracking table.
2331    let applied = match read_postgres_tracking_state_sync(&mut client, set)? {
2332        TrackingState::Missing => Vec::new(),
2333        TrackingState::Legacy(rows) => legacy_applied_records(set, &rows)?,
2334        TrackingState::Current => query_applied_records_postgres_sync(&mut client, set)?,
2335    };
2336    build_migration_plan(set, &applied)
2337}
2338
2339#[cfg(feature = "postgres-sync")]
2340fn query_applied_records_postgres_sync(
2341    client: &mut postgres::Client,
2342    set: &Migrations,
2343) -> Result<Vec<AppliedMigrationRecord>, CliError> {
2344    let rows = client
2345        .query(&set.applied_records_sql(), &[])
2346        .map_err(|error| CliError::MigrationError(error.to_string()))?;
2347
2348    let mut applied = Vec::new();
2349    for row in rows {
2350        let hash = row
2351            .try_get::<_, Option<String>>(0)
2352            .map_err(|error| CliError::MigrationError(error.to_string()))?;
2353        let name = row
2354            .try_get::<_, Option<String>>(1)
2355            .map_err(|error| CliError::MigrationError(error.to_string()))?;
2356        let dirty = row
2357            .try_get::<_, bool>(2)
2358            .map_err(|error| CliError::MigrationError(error.to_string()))?;
2359        if let (Some(hash), Some(name)) = (hash, name) {
2360            applied.push(AppliedMigrationRecord { hash, name, dirty });
2361        }
2362    }
2363
2364    Ok(applied)
2365}
2366
2367// ============================================================================
2368// PostgreSQL (tokio-postgres - async)
2369// ============================================================================
2370
2371#[cfg(feature = "tokio-postgres")]
2372fn execute_postgres_async_statements(
2373    creds: &PostgresCreds,
2374    statements: &[String],
2375) -> Result<(), CliError> {
2376    let rt = tokio::runtime::Builder::new_current_thread()
2377        .enable_all()
2378        .build()
2379        .map_err(|e| CliError::Other(format!("Failed to create async runtime: {}", e)))?;
2380
2381    rt.block_on(execute_postgres_async_inner(creds, statements))
2382}
2383
2384#[cfg(feature = "tokio-postgres")]
2385async fn execute_postgres_async_inner(
2386    creds: &PostgresCreds,
2387    statements: &[String],
2388) -> Result<(), CliError> {
2389    let mut client = connect_postgres_async(creds).await?;
2390
2391    if has_postgres_concurrent_index(statements) {
2392        // CREATE/DROP INDEX CONCURRENTLY cannot run inside a transaction block.
2393        for stmt in statements {
2394            let s = stmt.trim();
2395            if s.is_empty() {
2396                continue;
2397            }
2398            client
2399                .execute(s, &[])
2400                .await
2401                .map_err(|e| CliError::MigrationError(format!("Statement failed: {}\n{}", e, s)))?;
2402        }
2403    } else {
2404        let tx = client
2405            .transaction()
2406            .await
2407            .map_err(|e| CliError::MigrationError(e.to_string()))?;
2408
2409        for stmt in statements {
2410            let s = stmt.trim();
2411            if s.is_empty() {
2412                continue;
2413            }
2414            tx.execute(s, &[])
2415                .await
2416                .map_err(|e| CliError::MigrationError(format!("Statement failed: {}\n{}", e, s)))?;
2417        }
2418
2419        tx.commit()
2420            .await
2421            .map_err(|e| CliError::MigrationError(e.to_string()))?;
2422    }
2423
2424    Ok(())
2425}
2426
2427#[cfg(feature = "tokio-postgres")]
2428#[allow(dead_code)] // Used when postgres-sync is not enabled
2429fn run_postgres_async_migrations(
2430    set: &Migrations,
2431    creds: &PostgresCreds,
2432    repair: bool,
2433) -> Result<MigrationResult, CliError> {
2434    let rt = tokio::runtime::Builder::new_current_thread()
2435        .enable_all()
2436        .build()
2437        .map_err(|e| CliError::Other(format!("Failed to create async runtime: {e}")))?;
2438
2439    rt.block_on(run_postgres_async_inner(set, creds, repair))
2440}
2441
2442#[cfg(feature = "tokio-postgres")]
2443#[allow(dead_code)] // Used when postgres-sync is not enabled
2444fn inspect_postgres_async_migrations(
2445    set: &Migrations,
2446    creds: &PostgresCreds,
2447) -> Result<MigrationPlan, CliError> {
2448    let rt = tokio::runtime::Builder::new_current_thread()
2449        .enable_all()
2450        .build()
2451        .map_err(|e| CliError::Other(format!("Failed to create async runtime: {e}")))?;
2452
2453    rt.block_on(inspect_postgres_async_inner(set, creds))
2454}
2455
2456#[cfg(feature = "tokio-postgres")]
2457#[allow(dead_code)]
2458async fn inspect_postgres_async_inner(
2459    set: &Migrations,
2460    creds: &PostgresCreds,
2461) -> Result<MigrationPlan, CliError> {
2462    let client = connect_postgres_async(creds).await?;
2463
2464    // Read-only: no CREATE SCHEMA, no tracking table.
2465    let applied = match read_postgres_tracking_state_async(&client, set).await? {
2466        TrackingState::Missing => Vec::new(),
2467        TrackingState::Legacy(rows) => legacy_applied_records(set, &rows)?,
2468        TrackingState::Current => query_applied_records_postgres_async(&client, set).await?,
2469    };
2470    build_migration_plan(set, &applied)
2471}
2472
2473#[cfg(feature = "tokio-postgres")]
2474async fn query_applied_records_postgres_async(
2475    client: &tokio_postgres::Client,
2476    set: &Migrations,
2477) -> Result<Vec<AppliedMigrationRecord>, CliError> {
2478    let rows = client
2479        .query(&set.applied_records_sql(), &[])
2480        .await
2481        .map_err(|error| CliError::MigrationError(error.to_string()))?;
2482
2483    let mut applied = Vec::new();
2484    for row in rows {
2485        let hash = row
2486            .try_get::<_, Option<String>>(0)
2487            .map_err(|error| CliError::MigrationError(error.to_string()))?;
2488        let name = row
2489            .try_get::<_, Option<String>>(1)
2490            .map_err(|error| CliError::MigrationError(error.to_string()))?;
2491        let dirty = row
2492            .try_get::<_, bool>(2)
2493            .map_err(|error| CliError::MigrationError(error.to_string()))?;
2494        if let (Some(hash), Some(name)) = (hash, name) {
2495            applied.push(AppliedMigrationRecord { hash, name, dirty });
2496        }
2497    }
2498
2499    Ok(applied)
2500}
2501
2502#[cfg(feature = "tokio-postgres")]
2503#[allow(dead_code)]
2504async fn run_postgres_async_inner(
2505    set: &Migrations,
2506    creds: &PostgresCreds,
2507    repair: bool,
2508) -> Result<MigrationResult, CliError> {
2509    let mut client = connect_postgres_async(creds).await?;
2510
2511    // Create schema if needed
2512    if let Some(schema_sql) = set.create_schema_sql() {
2513        client
2514            .execute(&schema_sql, &[])
2515            .await
2516            .map_err(|e| CliError::MigrationError(e.to_string()))?;
2517    }
2518
2519    let lock_key = set.postgres_advisory_lock_key();
2520    client
2521        .query_one("SELECT pg_advisory_lock($1)", &[&lock_key])
2522        .await
2523        .map_err(|error| CliError::MigrationError(error.to_string()))?;
2524    let result = run_postgres_async_migrations_locked(&mut client, set, repair).await;
2525    let unlock = client
2526        .query_one("SELECT pg_advisory_unlock($1)", &[&lock_key])
2527        .await
2528        .map_err(|error| CliError::MigrationError(error.to_string()));
2529    match (result, unlock) {
2530        (Ok(result), Ok(_)) => Ok(result),
2531        (Err(error), _) => Err(error),
2532        (Ok(_), Err(error)) => Err(error),
2533    }
2534}
2535
2536#[cfg(feature = "tokio-postgres")]
2537async fn run_postgres_async_migrations_locked(
2538    client: &mut tokio_postgres::Client,
2539    set: &Migrations,
2540    repair: bool,
2541) -> Result<MigrationResult, CliError> {
2542    ensure_postgres_tracking_table_async(client, set).await?;
2543    let repaired = repair_dirty_migrations_postgres_async(client, set, repair).await?;
2544    let rows = client
2545        .query(&set.applied_names_sql(), &[])
2546        .await
2547        .map_err(|error| CliError::MigrationError(error.to_string()))?;
2548    let applied_names = rows
2549        .iter()
2550        .map(|row| row.try_get(0))
2551        .collect::<Result<Vec<String>, _>>()
2552        .map_err(|error| CliError::MigrationError(error.to_string()))?;
2553    let pending: Vec<_> = set.pending(&applied_names).collect();
2554    if pending.is_empty() {
2555        return Ok(MigrationResult {
2556            applied_count: 0,
2557            applied_migrations: vec![],
2558            repaired_migrations: repaired,
2559        });
2560    }
2561
2562    let mut applied = Vec::new();
2563    if set.has_postgres_concurrent_index() {
2564        // CREATE/DROP INDEX CONCURRENTLY cannot run in a transaction, so this
2565        // path uses two-phase tracking: mark the migration dirty, run the
2566        // statements autocommit, then stamp applied_at. A crash in between
2567        // leaves a recoverable dirty row instead of an untracked partial
2568        // schema.
2569        for migration in &pending {
2570            client
2571                .execute(&set.record_migration_started_sql(migration), &[])
2572                .await
2573                .map_err(|error| CliError::MigrationError(error.to_string()))?;
2574
2575            let mut executed = 0usize;
2576            for statement in migration.statements() {
2577                if statement.trim().is_empty() {
2578                    continue;
2579                }
2580                if let Err(error) = client.execute(statement, &[]).await {
2581                    // Nothing applied yet - drop the marker instead of
2582                    // demanding a pointless repair.
2583                    if executed == 0 {
2584                        let _ = client
2585                            .execute(&set.clear_migration_started_sql(migration), &[])
2586                            .await;
2587                    }
2588                    return Err(CliError::MigrationError(format!(
2589                        "Migration '{}' failed: {error}",
2590                        migration.hash()
2591                    )));
2592                }
2593                executed += 1;
2594            }
2595
2596            client
2597                .execute(&set.record_migration_finished_sql(migration), &[])
2598                .await
2599                .map_err(|error| CliError::MigrationError(error.to_string()))?;
2600            applied.push(migration.hash().to_string());
2601        }
2602    } else {
2603        let transaction = client
2604            .transaction()
2605            .await
2606            .map_err(|error| CliError::MigrationError(error.to_string()))?;
2607        for migration in &pending {
2608            for statement in migration.statements() {
2609                if !statement.trim().is_empty() {
2610                    transaction.execute(statement, &[]).await.map_err(|error| {
2611                        CliError::MigrationError(format!(
2612                            "Migration '{}' failed: {error}",
2613                            migration.hash()
2614                        ))
2615                    })?;
2616                }
2617            }
2618            transaction
2619                .execute(&set.record_migration_sql(migration), &[])
2620                .await
2621                .map_err(|error| CliError::MigrationError(error.to_string()))?;
2622            applied.push(migration.hash().to_string());
2623        }
2624        transaction
2625            .commit()
2626            .await
2627            .map_err(|error| CliError::MigrationError(error.to_string()))?;
2628    }
2629
2630    Ok(MigrationResult {
2631        applied_count: applied.len(),
2632        applied_migrations: applied,
2633        repaired_migrations: repaired,
2634    })
2635}
2636
2637// ============================================================================
2638// LibSQL (local)
2639// ============================================================================
2640
2641#[cfg(feature = "libsql")]
2642fn execute_libsql_local_statements(path: &str, statements: &[String]) -> Result<(), CliError> {
2643    let rt = tokio::runtime::Builder::new_current_thread()
2644        .enable_all()
2645        .build()
2646        .map_err(|e| CliError::Other(format!("Failed to create async runtime: {e}")))?;
2647
2648    rt.block_on(execute_libsql_local_inner(path, statements))
2649}
2650
2651#[cfg(feature = "libsql")]
2652async fn execute_libsql_local_inner(path: &str, statements: &[String]) -> Result<(), CliError> {
2653    let db = libsql::Builder::new_local(path)
2654        .build()
2655        .await
2656        .map_err(|e| {
2657            CliError::ConnectionError(format!("Failed to open LibSQL database '{path}': {e}"))
2658        })?;
2659
2660    let conn = db
2661        .connect()
2662        .map_err(|e| CliError::ConnectionError(e.to_string()))?;
2663
2664    execute_libsql_sqlite_statements_on_connection(&conn, statements).await
2665}
2666
2667#[cfg(any(feature = "libsql", feature = "turso"))]
2668async fn execute_libsql_sqlite_statements_on_connection(
2669    conn: &libsql::Connection,
2670    statements: &[String],
2671) -> Result<(), CliError> {
2672    let operation =
2673        drizzle_migrations::Migration::with_hash("libsql_push", "", 0, statements.to_vec());
2674    let execution = operation
2675        .sqlite_execution()
2676        .map_err(sqlite_execution_cli_error)?;
2677    let foreign_keys_were_enabled = sqlite_foreign_keys_enabled_libsql_cli(conn).await?;
2678    let suspend_foreign_keys = execution.suspends_foreign_keys() && foreign_keys_were_enabled;
2679    if suspend_foreign_keys
2680        && let Err(error) = set_sqlite_foreign_keys_libsql_cli(conn, false).await
2681    {
2682        let restore = set_sqlite_foreign_keys_libsql_cli(conn, true).await;
2683        return finish_sqlite_foreign_key_scope(Err(error), restore);
2684    }
2685
2686    let result = async {
2687        let tx = conn
2688            .transaction_with_behavior(libsql::TransactionBehavior::Immediate)
2689            .await
2690            .map_err(|error| CliError::MigrationError(error.to_string()))?;
2691        let transaction_result = async {
2692            for statement in execution
2693                .statements()
2694                .filter(|statement| !statement.trim().is_empty())
2695            {
2696                tx.execute(statement, ()).await.map_err(|error| {
2697                    CliError::MigrationError(format!("Statement failed: {error}\n{statement}"))
2698                })?;
2699            }
2700            if execution.suspends_foreign_keys() {
2701                verify_sqlite_foreign_keys_libsql_cli(&tx).await?;
2702            }
2703            Ok::<_, CliError>(())
2704        }
2705        .await;
2706        match transaction_result {
2707            Ok(()) => tx
2708                .commit()
2709                .await
2710                .map_err(|error| CliError::MigrationError(error.to_string())),
2711            Err(error) => {
2712                tx.rollback().await.ok();
2713                Err(error)
2714            }
2715        }
2716    }
2717    .await;
2718    let restore = if suspend_foreign_keys {
2719        set_sqlite_foreign_keys_libsql_cli(conn, true).await
2720    } else {
2721        Ok(())
2722    };
2723    finish_sqlite_foreign_key_scope(result, restore)
2724}
2725
2726#[cfg(feature = "libsql")]
2727fn run_libsql_local_migrations(
2728    set: &Migrations,
2729    path: &str,
2730    repair: bool,
2731) -> Result<MigrationResult, CliError> {
2732    let rt = tokio::runtime::Builder::new_current_thread()
2733        .enable_all()
2734        .build()
2735        .map_err(|e| CliError::Other(format!("Failed to create async runtime: {e}")))?;
2736
2737    rt.block_on(run_libsql_local_inner(set, path, repair))
2738}
2739
2740#[cfg(feature = "libsql")]
2741fn inspect_libsql_local_migrations(
2742    set: &Migrations,
2743    path: &str,
2744) -> Result<MigrationPlan, CliError> {
2745    let rt = tokio::runtime::Builder::new_current_thread()
2746        .enable_all()
2747        .build()
2748        .map_err(|e| CliError::Other(format!("Failed to create async runtime: {e}")))?;
2749
2750    rt.block_on(inspect_libsql_local_inner(set, path))
2751}
2752
2753#[cfg(feature = "libsql")]
2754async fn inspect_libsql_local_inner(
2755    set: &Migrations,
2756    path: &str,
2757) -> Result<MigrationPlan, CliError> {
2758    if !sqlite_database_exists(path) {
2759        return build_migration_plan(set, &[]);
2760    }
2761    let db = libsql::Builder::new_local(path)
2762        .build()
2763        .await
2764        .map_err(|e| {
2765            CliError::ConnectionError(format!("Failed to open LibSQL database '{path}': {e}"))
2766        })?;
2767
2768    let conn = db
2769        .connect()
2770        .map_err(|e| CliError::ConnectionError(e.to_string()))?;
2771
2772    let applied = match read_sqlite_tracking_state_libsql(&conn, set).await? {
2773        TrackingState::Missing => Vec::new(),
2774        TrackingState::Legacy(rows) => legacy_applied_records(set, &rows)?,
2775        TrackingState::Current => query_applied_records_libsql(&conn, set).await?,
2776    };
2777    build_migration_plan(set, &applied)
2778}
2779
2780#[cfg(feature = "libsql")]
2781async fn run_libsql_local_inner(
2782    set: &Migrations,
2783    path: &str,
2784    repair: bool,
2785) -> Result<MigrationResult, CliError> {
2786    let db = libsql::Builder::new_local(path)
2787        .build()
2788        .await
2789        .map_err(|e| {
2790            CliError::ConnectionError(format!("Failed to open LibSQL database '{path}': {e}"))
2791        })?;
2792
2793    let conn = db
2794        .connect()
2795        .map_err(|e| CliError::ConnectionError(e.to_string()))?;
2796
2797    run_libsql_sqlite_migrations_on_connection(set, &conn, repair).await
2798}
2799
2800#[cfg(any(feature = "libsql", feature = "turso"))]
2801async fn query_applied_names_libsql(
2802    conn: &libsql::Connection,
2803    set: &Migrations,
2804) -> Result<Vec<String>, CliError> {
2805    let mut rows = conn
2806        .query(&set.applied_names_sql(), ())
2807        .await
2808        .map_err(|error| CliError::MigrationError(error.to_string()))?;
2809
2810    let mut names = Vec::new();
2811    while let Some(row) = rows
2812        .next()
2813        .await
2814        .map_err(|error| CliError::MigrationError(error.to_string()))?
2815    {
2816        names.push(
2817            row.get::<String>(0)
2818                .map_err(|error| CliError::MigrationError(error.to_string()))?,
2819        );
2820    }
2821
2822    Ok(names)
2823}
2824
2825#[cfg(any(feature = "libsql", feature = "turso"))]
2826async fn run_libsql_sqlite_migrations_on_connection(
2827    set: &Migrations,
2828    conn: &libsql::Connection,
2829    repair: bool,
2830) -> Result<MigrationResult, CliError> {
2831    ensure_sqlite_tracking_table_libsql(conn, set).await?;
2832    let applied_before_transaction = query_applied_names_libsql(conn, set).await?;
2833    let suspends_foreign_keys = set
2834        .pending(&applied_before_transaction)
2835        .map(|migration| migration.sqlite_execution())
2836        .collect::<Result<Vec<_>, _>>()
2837        .map_err(sqlite_execution_cli_error)?
2838        .iter()
2839        .any(|execution| execution.suspends_foreign_keys());
2840    let foreign_keys_were_enabled = sqlite_foreign_keys_enabled_libsql_cli(conn).await?;
2841    let suspend_foreign_keys = suspends_foreign_keys && foreign_keys_were_enabled;
2842    if suspend_foreign_keys
2843        && let Err(error) = set_sqlite_foreign_keys_libsql_cli(conn, false).await
2844    {
2845        let restore = set_sqlite_foreign_keys_libsql_cli(conn, true).await;
2846        return finish_sqlite_foreign_key_scope(Err(error), restore);
2847    }
2848
2849    let result = async {
2850        let repaired = repair_dirty_migrations_libsql(conn, set, repair).await?;
2851        let tx = conn
2852            .transaction_with_behavior(libsql::TransactionBehavior::Immediate)
2853            .await
2854            .map_err(|error| CliError::MigrationError(error.to_string()))?;
2855        let transaction_result = async {
2856            let applied_names = query_applied_names_libsql(&tx, set).await?;
2857            let pending = set
2858                .pending(&applied_names)
2859                .map(|migration| {
2860                    migration
2861                        .sqlite_execution()
2862                        .map(|execution| (migration, execution))
2863                })
2864                .collect::<Result<Vec<_>, _>>()
2865                .map_err(sqlite_execution_cli_error)?;
2866
2867            let mut applied = Vec::with_capacity(pending.len());
2868            for (migration, execution) in pending {
2869                for statement in execution
2870                    .statements()
2871                    .filter(|statement| !statement.trim().is_empty())
2872                {
2873                    tx.execute(statement, ()).await.map_err(|error| {
2874                        CliError::MigrationError(format!(
2875                            "Migration '{}' failed: {error}",
2876                            migration.hash()
2877                        ))
2878                    })?;
2879                }
2880                tx.execute(&set.record_migration_sql(migration), ())
2881                    .await
2882                    .map_err(|error| CliError::MigrationError(error.to_string()))?;
2883                applied.push(migration.hash().to_string());
2884            }
2885
2886            Ok::<_, CliError>(MigrationResult {
2887                applied_count: applied.len(),
2888                applied_migrations: applied,
2889                repaired_migrations: repaired,
2890            })
2891        }
2892        .await;
2893
2894        match transaction_result {
2895            Ok(outcome) => {
2896                if suspends_foreign_keys
2897                    && let Err(error) = verify_sqlite_foreign_keys_libsql_cli(&tx).await
2898                {
2899                    tx.rollback().await.ok();
2900                    return Err(error);
2901                }
2902                tx.commit()
2903                    .await
2904                    .map_err(|error| CliError::MigrationError(error.to_string()))?;
2905                Ok(outcome)
2906            }
2907            Err(error) => {
2908                tx.rollback().await.ok();
2909                Err(error)
2910            }
2911        }
2912    }
2913    .await;
2914    let restore = if suspend_foreign_keys {
2915        set_sqlite_foreign_keys_libsql_cli(conn, true).await
2916    } else {
2917        Ok(())
2918    };
2919    finish_sqlite_foreign_key_scope(result, restore)
2920}
2921
2922#[cfg(any(feature = "libsql", feature = "turso"))]
2923async fn sqlite_foreign_keys_enabled_libsql_cli(
2924    conn: &libsql::Connection,
2925) -> Result<bool, CliError> {
2926    let mut rows = conn
2927        .query("PRAGMA foreign_keys", ())
2928        .await
2929        .map_err(|error| CliError::MigrationError(error.to_string()))?;
2930    let row = rows
2931        .next()
2932        .await
2933        .map_err(|error| CliError::MigrationError(error.to_string()))?
2934        .ok_or_else(|| CliError::MigrationError("PRAGMA foreign_keys returned no row".into()))?;
2935    row.get::<i64>(0)
2936        .map(|enabled| enabled != 0)
2937        .map_err(|error| CliError::MigrationError(error.to_string()))
2938}
2939
2940#[cfg(any(feature = "libsql", feature = "turso"))]
2941async fn set_sqlite_foreign_keys_libsql_cli(
2942    conn: &libsql::Connection,
2943    enabled: bool,
2944) -> Result<(), CliError> {
2945    conn.execute(
2946        if enabled {
2947            "PRAGMA foreign_keys=ON"
2948        } else {
2949            "PRAGMA foreign_keys=OFF"
2950        },
2951        (),
2952    )
2953    .await
2954    .map_err(|error| CliError::MigrationError(error.to_string()))?;
2955    if sqlite_foreign_keys_enabled_libsql_cli(conn).await? != enabled {
2956        return Err(CliError::MigrationError(format!(
2957            "SQLite refused to set foreign_keys={}",
2958            if enabled { "ON" } else { "OFF" }
2959        )));
2960    }
2961    Ok(())
2962}
2963
2964#[cfg(any(feature = "libsql", feature = "turso"))]
2965async fn verify_sqlite_foreign_keys_libsql_cli(conn: &libsql::Connection) -> Result<(), CliError> {
2966    let mut rows = conn
2967        .query("PRAGMA foreign_key_check", ())
2968        .await
2969        .map_err(|error| CliError::MigrationError(error.to_string()))?;
2970    if rows
2971        .next()
2972        .await
2973        .map_err(|error| CliError::MigrationError(error.to_string()))?
2974        .is_some()
2975    {
2976        return Err(CliError::MigrationError(
2977            "SQLite foreign_key_check failed after migration rebuild".into(),
2978        ));
2979    }
2980    Ok(())
2981}
2982
2983#[cfg(feature = "libsql")]
2984async fn query_applied_records_libsql(
2985    conn: &libsql::Connection,
2986    set: &Migrations,
2987) -> Result<Vec<AppliedMigrationRecord>, CliError> {
2988    let mut rows = conn
2989        .query(&set.applied_records_sql(), ())
2990        .await
2991        .map_err(|error| CliError::MigrationError(error.to_string()))?;
2992
2993    let mut applied = Vec::new();
2994    while let Some(row) = rows
2995        .next()
2996        .await
2997        .map_err(|error| CliError::MigrationError(error.to_string()))?
2998    {
2999        let hash = row
3000            .get::<String>(0)
3001            .map_err(|error| CliError::MigrationError(error.to_string()))?;
3002        let name = row
3003            .get::<String>(1)
3004            .map_err(|error| CliError::MigrationError(error.to_string()))?;
3005        let dirty = row
3006            .get::<i64>(2)
3007            .map_err(|error| CliError::MigrationError(error.to_string()))?
3008            != 0;
3009        applied.push(AppliedMigrationRecord { hash, name, dirty });
3010    }
3011
3012    Ok(applied)
3013}
3014
3015// ============================================================================
3016// Turso (remote)
3017// ============================================================================
3018
3019#[cfg(feature = "turso")]
3020fn execute_turso_statements(
3021    url: &str,
3022    auth_token: Option<&str>,
3023    statements: &[String],
3024) -> Result<(), CliError> {
3025    let rt = tokio::runtime::Builder::new_current_thread()
3026        .enable_all()
3027        .build()
3028        .map_err(|e| CliError::Other(format!("Failed to create async runtime: {e}")))?;
3029
3030    rt.block_on(execute_turso_inner(url, auth_token, statements))
3031}
3032
3033#[cfg(feature = "turso")]
3034async fn execute_turso_inner(
3035    url: &str,
3036    auth_token: Option<&str>,
3037    statements: &[String],
3038) -> Result<(), CliError> {
3039    let builder =
3040        libsql::Builder::new_remote(url.to_string(), auth_token.unwrap_or("").to_string());
3041
3042    let db = builder.build().await.map_err(|e| {
3043        CliError::ConnectionError(format!("Failed to connect to Turso '{url}': {e}"))
3044    })?;
3045
3046    let conn = db
3047        .connect()
3048        .map_err(|e| CliError::ConnectionError(e.to_string()))?;
3049
3050    execute_libsql_sqlite_statements_on_connection(&conn, statements).await
3051}
3052
3053#[cfg(feature = "turso")]
3054fn run_turso_migrations(
3055    set: &Migrations,
3056    url: &str,
3057    auth_token: Option<&str>,
3058    repair: bool,
3059) -> Result<MigrationResult, CliError> {
3060    let rt = tokio::runtime::Builder::new_current_thread()
3061        .enable_all()
3062        .build()
3063        .map_err(|e| CliError::Other(format!("Failed to create async runtime: {e}")))?;
3064
3065    rt.block_on(run_turso_inner(set, url, auth_token, repair))
3066}
3067
3068#[cfg(feature = "turso")]
3069fn inspect_turso_migrations(
3070    set: &Migrations,
3071    url: &str,
3072    auth_token: Option<&str>,
3073) -> Result<MigrationPlan, CliError> {
3074    let rt = tokio::runtime::Builder::new_current_thread()
3075        .enable_all()
3076        .build()
3077        .map_err(|e| CliError::Other(format!("Failed to create async runtime: {e}")))?;
3078
3079    rt.block_on(inspect_turso_inner(set, url, auth_token))
3080}
3081
3082#[cfg(feature = "turso")]
3083async fn inspect_turso_inner(
3084    set: &Migrations,
3085    url: &str,
3086    auth_token: Option<&str>,
3087) -> Result<MigrationPlan, CliError> {
3088    let builder =
3089        libsql::Builder::new_remote(url.to_string(), auth_token.unwrap_or("").to_string());
3090
3091    let db = builder.build().await.map_err(|e| {
3092        CliError::ConnectionError(format!("Failed to connect to Turso '{url}': {e}"))
3093    })?;
3094
3095    let conn = db
3096        .connect()
3097        .map_err(|e| CliError::ConnectionError(e.to_string()))?;
3098
3099    let applied = match read_sqlite_tracking_state_libsql(&conn, set).await? {
3100        TrackingState::Missing => Vec::new(),
3101        TrackingState::Legacy(rows) => legacy_applied_records(set, &rows)?,
3102        TrackingState::Current => query_applied_records_turso(&conn, set).await?,
3103    };
3104    build_migration_plan(set, &applied)
3105}
3106
3107#[cfg(feature = "turso")]
3108async fn run_turso_inner(
3109    set: &Migrations,
3110    url: &str,
3111    auth_token: Option<&str>,
3112    repair: bool,
3113) -> Result<MigrationResult, CliError> {
3114    let builder =
3115        libsql::Builder::new_remote(url.to_string(), auth_token.unwrap_or("").to_string());
3116
3117    let db = builder.build().await.map_err(|e| {
3118        CliError::ConnectionError(format!("Failed to connect to Turso '{url}': {e}"))
3119    })?;
3120
3121    let conn = db
3122        .connect()
3123        .map_err(|e| CliError::ConnectionError(e.to_string()))?;
3124
3125    run_libsql_sqlite_migrations_on_connection(set, &conn, repair).await
3126}
3127
3128#[cfg(feature = "turso")]
3129async fn query_applied_records_turso(
3130    conn: &libsql::Connection,
3131    set: &Migrations,
3132) -> Result<Vec<AppliedMigrationRecord>, CliError> {
3133    let mut rows = conn
3134        .query(&set.applied_records_sql(), ())
3135        .await
3136        .map_err(|error| CliError::MigrationError(error.to_string()))?;
3137
3138    let mut applied = Vec::new();
3139    while let Some(row) = rows
3140        .next()
3141        .await
3142        .map_err(|error| CliError::MigrationError(error.to_string()))?
3143    {
3144        let hash = row
3145            .get::<String>(0)
3146            .map_err(|error| CliError::MigrationError(error.to_string()))?;
3147        let name = row
3148            .get::<String>(1)
3149            .map_err(|error| CliError::MigrationError(error.to_string()))?;
3150        let dirty = row
3151            .get::<i64>(2)
3152            .map_err(|error| CliError::MigrationError(error.to_string()))?
3153            != 0;
3154        applied.push(AppliedMigrationRecord { hash, name, dirty });
3155    }
3156
3157    Ok(applied)
3158}
3159
3160// ============================================================================
3161// Database Introspection
3162// ============================================================================
3163
3164/// Result of database introspection
3165#[derive(Debug)]
3166pub struct IntrospectResult {
3167    /// Generated Rust schema code
3168    pub schema_code: String,
3169    /// Number of tables found
3170    pub table_count: usize,
3171    /// Number of indexes found
3172    pub index_count: usize,
3173    /// Number of views found
3174    pub view_count: usize,
3175    /// Any warnings during introspection
3176    pub warnings: Vec<String>,
3177    /// Live MySQL defaults used only while comparing an introspected schema
3178    /// with a desired schema during push planning.
3179    pub mysql_catalog_defaults: Option<drizzle_migrations::mysql::MySQLCatalogDefaults>,
3180    /// The schema snapshot for migration tracking
3181    pub snapshot: Snapshot,
3182    /// Path to the generated snapshot file
3183    pub snapshot_path: std::path::PathBuf,
3184}
3185
3186/// Introspect a database and write schema/snapshot files.
3187///
3188/// This is the main entry point for CLI introspection.
3189///
3190/// # Errors
3191///
3192/// Returns [`CliError`] if connecting to the database fails, if querying the
3193/// catalogs fails, if applying the configured snapshot filters fails, or if
3194/// writing the generated schema and snapshot files to disk fails.
3195#[allow(clippy::too_many_arguments)]
3196pub fn run_introspection(
3197    connection: &ResolvedConnection,
3198    out_dir: &Path,
3199    init_metadata: bool,
3200    breakpoints: bool,
3201    introspect_casing: Option<IntrospectCasing>,
3202    filters: &SnapshotFilters,
3203    migrations_table: &str,
3204    migrations_schema: &str,
3205) -> Result<IntrospectResult, CliError> {
3206    use drizzle_migrations::naming::generate_migration_tag;
3207
3208    // Perform introspection
3209    let mut result = introspect_database(connection)?;
3210    exclude_tracking_table(&mut result.snapshot, connection.dialect, migrations_table)?;
3211    apply_snapshot_filters(&mut result.snapshot, connection.dialect, filters)?;
3212    if !filters.is_empty() || introspect_casing.is_some() {
3213        regenerate_schema_from_snapshot(&mut result, connection.dialect, introspect_casing);
3214    }
3215
3216    // Write schema file
3217    let schema_path = out_dir.join("schema.rs");
3218    if let Some(parent) = schema_path.parent() {
3219        std::fs::create_dir_all(parent).map_err(|e| {
3220            CliError::Other(format!(
3221                "Failed to create output directory '{}': {}",
3222                parent.display(),
3223                e
3224            ))
3225        })?;
3226    }
3227    std::fs::write(&schema_path, &result.schema_code).map_err(|e| {
3228        CliError::Other(format!(
3229            "Failed to write schema file '{}': {}",
3230            schema_path.display(),
3231            e
3232        ))
3233    })?;
3234
3235    let journal_path = out_dir.join("meta").join("_journal.json");
3236    if journal_path.exists() {
3237        return Err(CliError::Other(
3238            "Detected old drizzle-kit migration folders. Upgrade them before writing new migrations."
3239                .to_string(),
3240        ));
3241    }
3242
3243    // Generate migration tag (V3 format: timestamp-based)
3244    let tag = generate_migration_tag(None);
3245
3246    // Create migration directory: {out}/{tag}/
3247    let migration_dir = out_dir.join(&tag);
3248    std::fs::create_dir_all(&migration_dir).map_err(|e| {
3249        CliError::Other(format!(
3250            "Failed to create migration directory '{}': {}",
3251            migration_dir.display(),
3252            e
3253        ))
3254    })?;
3255
3256    // Save snapshot JSON: {out}/{tag}/snapshot.json
3257    let snapshot_path = migration_dir.join("snapshot.json");
3258    result.snapshot.save(&snapshot_path).map_err(|e| {
3259        CliError::Other(format!(
3260            "Failed to write snapshot file '{}': {}",
3261            snapshot_path.display(),
3262            e
3263        ))
3264    })?;
3265
3266    // Generate initial migration SQL by diffing against empty snapshot
3267    let base_dialect = connection.dialect.to_base();
3268    let empty_snapshot = Snapshot::empty(base_dialect);
3269    let sql_statements =
3270        generate_introspect_migration(&empty_snapshot, &result.snapshot, breakpoints)?;
3271
3272    // Write migration.sql: {out}/{tag}/migration.sql
3273    let migration_sql_path = migration_dir.join("migration.sql");
3274    let sql_content = format_migration_sql(&sql_statements, breakpoints);
3275    std::fs::write(&migration_sql_path, &sql_content).map_err(|e| {
3276        CliError::Other(format!(
3277            "Failed to write migration file '{}': {}",
3278            migration_sql_path.display(),
3279            e
3280        ))
3281    })?;
3282
3283    // Update result with path
3284    result.snapshot_path = snapshot_path;
3285
3286    if init_metadata {
3287        apply_init_metadata(connection, out_dir, migrations_table, migrations_schema)?;
3288    }
3289
3290    Ok(result)
3291}
3292
3293// =============================================================================
3294// Init metadata handling
3295// =============================================================================
3296
3297#[allow(unused_variables)] // params consumed inside feature-gated block
3298fn apply_init_metadata(
3299    connection: &ResolvedConnection,
3300    out_dir: &Path,
3301    migrations_table: &str,
3302    migrations_schema: &str,
3303) -> Result<(), CliError> {
3304    let set = load_migration_set(
3305        connection.dialect,
3306        out_dir,
3307        migrations_table,
3308        migrations_schema,
3309    )?;
3310
3311    match &connection.credentials {
3312        #[cfg(feature = "rusqlite")]
3313        Credentials::Sqlite { path } if connection.driver == Driver::Rusqlite => {
3314            init_sqlite_metadata(path, &set)
3315        }
3316
3317        #[cfg(feature = "rusqlite")]
3318        Credentials::Sqlite { .. } => Err(connection_driver_mismatch(connection)),
3319
3320        #[cfg(not(feature = "rusqlite"))]
3321        Credentials::Sqlite { .. } => Err(CliError::MissingDriver {
3322            dialect: "SQLite",
3323            feature: "rusqlite",
3324        }),
3325
3326        #[cfg(any(feature = "libsql", feature = "turso"))]
3327        Credentials::Turso { url, auth_token } => {
3328            if connection.driver == Driver::Libsql {
3329                #[cfg(feature = "libsql")]
3330                {
3331                    init_libsql_local_metadata(url, &set)
3332                }
3333                #[cfg(not(feature = "libsql"))]
3334                {
3335                    Err(CliError::MissingDriver {
3336                        dialect: "LibSQL (local)",
3337                        feature: "libsql",
3338                    })
3339                }
3340            } else if connection.driver == Driver::Turso {
3341                #[cfg(feature = "turso")]
3342                {
3343                    init_turso_metadata(url, auth_token.as_deref(), &set)
3344                }
3345                #[cfg(not(feature = "turso"))]
3346                {
3347                    let _ = auth_token;
3348                    Err(CliError::MissingDriver {
3349                        dialect: "Turso (remote)",
3350                        feature: "turso",
3351                    })
3352                }
3353            } else {
3354                Err(connection_driver_mismatch(connection))
3355            }
3356        }
3357
3358        #[cfg(all(not(feature = "turso"), not(feature = "libsql")))]
3359        Credentials::Turso { .. } => Err(CliError::MissingDriver {
3360            dialect: "Turso",
3361            feature: "turso or libsql",
3362        }),
3363
3364        Credentials::Postgres(creds) => {
3365            let _ = creds;
3366            match connection.driver {
3367                Driver::PostgresSync => {
3368                    #[cfg(feature = "postgres-sync")]
3369                    {
3370                        init_postgres_sync_metadata(creds, &set)
3371                    }
3372                    #[cfg(not(feature = "postgres-sync"))]
3373                    {
3374                        Err(CliError::MissingDriver {
3375                            dialect: "PostgreSQL",
3376                            feature: "postgres-sync",
3377                        })
3378                    }
3379                }
3380                Driver::TokioPostgres => {
3381                    #[cfg(feature = "tokio-postgres")]
3382                    {
3383                        init_postgres_async_metadata(creds, &set)
3384                    }
3385                    #[cfg(not(feature = "tokio-postgres"))]
3386                    {
3387                        Err(CliError::MissingDriver {
3388                            dialect: "PostgreSQL",
3389                            feature: "tokio-postgres",
3390                        })
3391                    }
3392                }
3393                _ => Err(connection_driver_mismatch(connection)),
3394            }
3395        }
3396
3397        Credentials::MySQL(creds) => mysql::init_metadata(connection.driver, creds, &set),
3398
3399        #[cfg(feature = "d1-http")]
3400        Credentials::D1 {
3401            account_id,
3402            database_id,
3403            token,
3404        } if connection.driver == Driver::D1Http => {
3405            d1_http::init_metadata(&set, account_id, database_id, token)
3406        }
3407
3408        #[cfg(feature = "d1-http")]
3409        Credentials::D1 { .. } => Err(connection_driver_mismatch(connection)),
3410
3411        #[cfg(not(feature = "d1-http"))]
3412        Credentials::D1 { .. } => Err(CliError::MissingDriver {
3413            dialect: "Cloudflare D1 (HTTP)",
3414            feature: "d1-http",
3415        }),
3416
3417        Credentials::AwsDataApi { .. } => Err(CliError::UnsupportedForDriver {
3418            operation: "Migration metadata init against AWS Data API",
3419            driver: "aws-data-api",
3420            hint: "AWS RDS Data API schema ops are not yet wired into this CLI. Seed the \
3421                   migrations table manually via `aws rds-data execute-statement` or \
3422                   tokio-postgres.",
3423        }),
3424    }
3425}
3426
3427#[cfg(any(
3428    feature = "rusqlite",
3429    feature = "libsql",
3430    feature = "turso",
3431    feature = "postgres-sync",
3432    feature = "tokio-postgres",
3433    feature = "mysql-sync",
3434    feature = "mysql-async",
3435    feature = "d1-http",
3436))]
3437pub(crate) fn validate_init_metadata(
3438    applied_names: &[String],
3439    set: &Migrations,
3440) -> Result<(), CliError> {
3441    if !applied_names.is_empty() {
3442        return Err(CliError::Other(
3443            "--init can't be used when database already has migrations set".into(),
3444        ));
3445    }
3446
3447    if set.all().len() > 1 {
3448        return Err(CliError::Other(
3449            "--init can't be used with existing migrations".into(),
3450        ));
3451    }
3452
3453    Ok(())
3454}
3455
3456// =============================================================================
3457// Init metadata implementations
3458// =============================================================================
3459
3460#[cfg(feature = "rusqlite")]
3461fn init_sqlite_metadata(path: &str, set: &Migrations) -> Result<(), CliError> {
3462    let conn = rusqlite::Connection::open(path).map_err(|e| {
3463        CliError::ConnectionError(format!("Failed to open SQLite database '{path}': {e}"))
3464    })?;
3465
3466    ensure_sqlite_tracking_table(&conn, set)?;
3467
3468    let applied_names = query_applied_names_sqlite(&conn, set)?;
3469    validate_init_metadata(&applied_names, set)?;
3470
3471    let Some(first) = set.all().first() else {
3472        return Ok(());
3473    };
3474
3475    conn.execute(&set.record_migration_sql(first), [])
3476        .map_err(|e| CliError::MigrationError(e.to_string()))?;
3477
3478    Ok(())
3479}
3480
3481#[cfg(feature = "libsql")]
3482fn init_libsql_local_metadata(path: &str, set: &Migrations) -> Result<(), CliError> {
3483    let rt = tokio::runtime::Builder::new_current_thread()
3484        .enable_all()
3485        .build()
3486        .map_err(|e| CliError::Other(format!("Failed to create async runtime: {e}")))?;
3487
3488    rt.block_on(init_libsql_local_metadata_inner(path, set))
3489}
3490
3491#[cfg(feature = "libsql")]
3492async fn init_libsql_local_metadata_inner(path: &str, set: &Migrations) -> Result<(), CliError> {
3493    let db = libsql::Builder::new_local(path)
3494        .build()
3495        .await
3496        .map_err(|e| {
3497            CliError::ConnectionError(format!("Failed to open LibSQL database '{path}': {e}"))
3498        })?;
3499
3500    let conn = db
3501        .connect()
3502        .map_err(|e| CliError::ConnectionError(e.to_string()))?;
3503
3504    ensure_sqlite_tracking_table_libsql(&conn, set).await?;
3505
3506    let applied_names = query_applied_names_libsql(&conn, set).await?;
3507    validate_init_metadata(&applied_names, set)?;
3508
3509    let Some(first) = set.all().first() else {
3510        return Ok(());
3511    };
3512
3513    conn.execute(&set.record_migration_sql(first), ())
3514        .await
3515        .map_err(|e| CliError::MigrationError(e.to_string()))?;
3516
3517    Ok(())
3518}
3519
3520#[cfg(feature = "turso")]
3521fn init_turso_metadata(
3522    url: &str,
3523    auth_token: Option<&str>,
3524    set: &Migrations,
3525) -> Result<(), CliError> {
3526    let rt = tokio::runtime::Builder::new_current_thread()
3527        .enable_all()
3528        .build()
3529        .map_err(|e| CliError::Other(format!("Failed to create async runtime: {e}")))?;
3530
3531    rt.block_on(init_turso_metadata_inner(url, auth_token, set))
3532}
3533
3534#[cfg(feature = "turso")]
3535async fn init_turso_metadata_inner(
3536    url: &str,
3537    auth_token: Option<&str>,
3538    set: &Migrations,
3539) -> Result<(), CliError> {
3540    let builder =
3541        libsql::Builder::new_remote(url.to_string(), auth_token.unwrap_or("").to_string());
3542
3543    let db = builder.build().await.map_err(|e| {
3544        CliError::ConnectionError(format!("Failed to connect to Turso '{url}': {e}"))
3545    })?;
3546
3547    let conn = db
3548        .connect()
3549        .map_err(|e| CliError::ConnectionError(e.to_string()))?;
3550
3551    ensure_sqlite_tracking_table_libsql(&conn, set).await?;
3552
3553    let applied_names = query_applied_names_libsql(&conn, set).await?;
3554    validate_init_metadata(&applied_names, set)?;
3555
3556    let Some(first) = set.all().first() else {
3557        return Ok(());
3558    };
3559
3560    conn.execute(&set.record_migration_sql(first), ())
3561        .await
3562        .map_err(|e| CliError::MigrationError(e.to_string()))?;
3563
3564    Ok(())
3565}
3566
3567#[cfg(feature = "postgres-sync")]
3568fn init_postgres_sync_metadata(creds: &PostgresCreds, set: &Migrations) -> Result<(), CliError> {
3569    let mut client = connect_postgres_sync(creds)?;
3570
3571    if let Some(schema_sql) = set.create_schema_sql() {
3572        client
3573            .execute(&schema_sql, &[])
3574            .map_err(|e| CliError::MigrationError(e.to_string()))?;
3575    }
3576
3577    ensure_postgres_tracking_table_sync(&mut client, set)?;
3578
3579    let rows = client
3580        .query(&set.applied_names_sql(), &[])
3581        .map_err(|error| CliError::MigrationError(error.to_string()))?;
3582    let applied_names = rows
3583        .iter()
3584        .map(|row| row.try_get(0))
3585        .collect::<Result<Vec<String>, _>>()
3586        .map_err(|error| CliError::MigrationError(error.to_string()))?;
3587
3588    validate_init_metadata(&applied_names, set)?;
3589
3590    let Some(first) = set.all().first() else {
3591        return Ok(());
3592    };
3593
3594    client
3595        .execute(&set.record_migration_sql(first), &[])
3596        .map_err(|e| CliError::MigrationError(e.to_string()))?;
3597
3598    Ok(())
3599}
3600
3601#[cfg(feature = "tokio-postgres")]
3602fn init_postgres_async_metadata(creds: &PostgresCreds, set: &Migrations) -> Result<(), CliError> {
3603    let rt = tokio::runtime::Builder::new_current_thread()
3604        .enable_all()
3605        .build()
3606        .map_err(|e| CliError::Other(format!("Failed to create async runtime: {}", e)))?;
3607
3608    rt.block_on(init_postgres_async_inner(creds, set))
3609}
3610
3611#[cfg(feature = "tokio-postgres")]
3612async fn init_postgres_async_inner(
3613    creds: &PostgresCreds,
3614    set: &Migrations,
3615) -> Result<(), CliError> {
3616    let client = connect_postgres_async(creds).await?;
3617
3618    if let Some(schema_sql) = set.create_schema_sql() {
3619        client
3620            .execute(&schema_sql, &[])
3621            .await
3622            .map_err(|e| CliError::MigrationError(e.to_string()))?;
3623    }
3624
3625    ensure_postgres_tracking_table_async(&client, set).await?;
3626
3627    let rows = client
3628        .query(&set.applied_names_sql(), &[])
3629        .await
3630        .map_err(|error| CliError::MigrationError(error.to_string()))?;
3631    let applied_names = rows
3632        .iter()
3633        .map(|row| row.try_get(0))
3634        .collect::<Result<Vec<String>, _>>()
3635        .map_err(|error| CliError::MigrationError(error.to_string()))?;
3636
3637    validate_init_metadata(&applied_names, set)?;
3638
3639    let Some(first) = set.all().first() else {
3640        return Ok(());
3641    };
3642
3643    client
3644        .execute(&set.record_migration_sql(first), &[])
3645        .await
3646        .map_err(|e| CliError::MigrationError(e.to_string()))?;
3647
3648    Ok(())
3649}
3650
3651/// Generate migration SQL from snapshot diff (for introspection)
3652fn generate_introspect_migration(
3653    prev: &Snapshot,
3654    current: &Snapshot,
3655    _breakpoints: bool,
3656) -> Result<Vec<String>, CliError> {
3657    drizzle_migrations::diff(prev, current)
3658        .map(|plan| plan.statements)
3659        .map_err(|error| CliError::MigrationError(error.to_string()))
3660}
3661
3662fn format_migration_sql(sql_statements: &[String], breakpoints: bool) -> String {
3663    if sql_statements.is_empty() {
3664        "-- No tables to create (empty database)\n".to_string()
3665    } else if breakpoints {
3666        sql_statements.join("\n--> statement-breakpoint\n")
3667    } else {
3668        sql_statements.join("\n\n")
3669    }
3670}
3671
3672fn regenerate_schema_from_snapshot(
3673    result: &mut IntrospectResult,
3674    dialect: Dialect,
3675    introspect_casing: Option<IntrospectCasing>,
3676) {
3677    match (&result.snapshot, dialect) {
3678        (Snapshot::Sqlite(snap), Dialect::Sqlite | Dialect::Turso) => {
3679            use drizzle_migrations::sqlite::SQLiteDDL;
3680            use drizzle_migrations::sqlite::codegen::{
3681                CodegenOptions, FieldCasing, generate_rust_schema,
3682            };
3683
3684            let field_casing = match introspect_casing {
3685                Some(IntrospectCasing::Camel) => FieldCasing::Camel,
3686                Some(IntrospectCasing::Preserve) => FieldCasing::Preserve,
3687                None => FieldCasing::Snake,
3688            };
3689
3690            let ddl = SQLiteDDL::from_entities(snap.ddl.clone());
3691            let generated = generate_rust_schema(
3692                &ddl,
3693                &CodegenOptions {
3694                    module_doc: Some("Schema introspected from filtered database objects".into()),
3695                    include_schema: true,
3696                    schema_name: "Schema".into(),
3697                    use_pub: true,
3698                    field_casing,
3699                },
3700            );
3701
3702            result.schema_code = generated.code;
3703            result.table_count = generated.tables.len();
3704            result.index_count = generated.indexes.len();
3705            result.view_count = ddl.views.list().len();
3706            result.warnings = generated.warnings;
3707        }
3708        (Snapshot::Postgres(snap), Dialect::Postgresql) => {
3709            use drizzle_migrations::postgres::PostgresDDL;
3710            use drizzle_migrations::postgres::codegen::{
3711                CodegenOptions, FieldCasing, generate_rust_schema,
3712            };
3713
3714            let field_casing = match introspect_casing {
3715                Some(IntrospectCasing::Camel) => FieldCasing::Camel,
3716                Some(IntrospectCasing::Preserve) => FieldCasing::Preserve,
3717                None => FieldCasing::Snake,
3718            };
3719
3720            let ddl = PostgresDDL::from_entities(snap.ddl.clone());
3721            let generated = generate_rust_schema(
3722                &ddl,
3723                &CodegenOptions {
3724                    module_doc: Some("Schema introspected from filtered database objects".into()),
3725                    include_schema: true,
3726                    schema_name: "Schema".into(),
3727                    use_pub: true,
3728                    field_casing,
3729                },
3730            );
3731
3732            result.schema_code = generated.code;
3733            result.table_count = generated.tables.len();
3734            result.index_count = generated.indexes.len();
3735            result.view_count = generated.views.len();
3736            result.warnings = generated.warnings;
3737        }
3738        (Snapshot::MySQL(snap), Dialect::Mysql) => {
3739            use drizzle_migrations::mysql::MySQLDDL;
3740            use drizzle_migrations::mysql::codegen::{
3741                CodegenOptions, FieldCasing, generate_rust_schema,
3742            };
3743
3744            let field_casing = match introspect_casing {
3745                Some(IntrospectCasing::Camel) => FieldCasing::Camel,
3746                Some(IntrospectCasing::Preserve) => FieldCasing::Preserve,
3747                None => FieldCasing::Snake,
3748            };
3749            let ddl = MySQLDDL::from_entities(snap.ddl.clone());
3750            let generated = generate_rust_schema(
3751                &ddl,
3752                &CodegenOptions {
3753                    module_doc: Some("Schema introspected from filtered database objects".into()),
3754                    include_schema: true,
3755                    schema_name: "Schema".into(),
3756                    use_pub: true,
3757                    field_casing,
3758                },
3759            )
3760            .expect("initial MySQL introspection already validated lossless code generation");
3761
3762            result.schema_code = generated.code;
3763            result.table_count = generated.tables.len();
3764            result.index_count = generated.indexes.len();
3765            result.view_count = generated.views.len();
3766            result.warnings = generated.warnings;
3767        }
3768        _ => {}
3769    }
3770}
3771
3772/// Introspect a database and generate schema code
3773fn introspect_database(connection: &ResolvedConnection) -> Result<IntrospectResult, CliError> {
3774    match connection.dialect {
3775        Dialect::Sqlite | Dialect::Turso => introspect_sqlite_dialect(connection),
3776        Dialect::Postgresql => introspect_postgres_dialect(connection),
3777        Dialect::Mysql => match &connection.credentials {
3778            Credentials::MySQL(creds) => mysql::introspect(connection.driver, creds),
3779            _ => Err(connection_driver_mismatch(connection)),
3780        },
3781    }
3782}
3783
3784/// Introspect SQLite-family databases
3785fn introspect_sqlite_dialect(
3786    connection: &ResolvedConnection,
3787) -> Result<IntrospectResult, CliError> {
3788    match &connection.credentials {
3789        #[cfg(feature = "rusqlite")]
3790        Credentials::Sqlite { path } if connection.driver == Driver::Rusqlite => {
3791            introspect_rusqlite(path)
3792        }
3793
3794        #[cfg(feature = "rusqlite")]
3795        Credentials::Sqlite { .. } => Err(connection_driver_mismatch(connection)),
3796
3797        #[cfg(not(feature = "rusqlite"))]
3798        Credentials::Sqlite { .. } => Err(CliError::MissingDriver {
3799            dialect: "SQLite",
3800            feature: "rusqlite",
3801        }),
3802
3803        #[cfg(any(feature = "libsql", feature = "turso"))]
3804        Credentials::Turso { url, auth_token } => {
3805            if connection.driver == Driver::Libsql {
3806                #[cfg(feature = "libsql")]
3807                {
3808                    introspect_libsql_local(url)
3809                }
3810                #[cfg(not(feature = "libsql"))]
3811                {
3812                    Err(CliError::MissingDriver {
3813                        dialect: "LibSQL (local)",
3814                        feature: "libsql",
3815                    })
3816                }
3817            } else if connection.driver == Driver::Turso {
3818                #[cfg(feature = "turso")]
3819                {
3820                    introspect_turso(url, auth_token.as_deref())
3821                }
3822                #[cfg(not(feature = "turso"))]
3823                {
3824                    let _ = auth_token;
3825                    Err(CliError::MissingDriver {
3826                        dialect: "Turso (remote)",
3827                        feature: "turso",
3828                    })
3829                }
3830            } else {
3831                Err(connection_driver_mismatch(connection))
3832            }
3833        }
3834
3835        #[cfg(all(not(feature = "turso"), not(feature = "libsql")))]
3836        Credentials::Turso { .. } => Err(CliError::MissingDriver {
3837            dialect: "Turso",
3838            feature: "turso or libsql",
3839        }),
3840
3841        _ => Err(CliError::Other(
3842            "SQLite introspection requires sqlite path or turso credentials".into(),
3843        )),
3844    }
3845}
3846
3847/// Introspect `PostgreSQL` databases
3848fn introspect_postgres_dialect(
3849    connection: &ResolvedConnection,
3850) -> Result<IntrospectResult, CliError> {
3851    match &connection.credentials {
3852        Credentials::Postgres(creds) => {
3853            let _ = creds;
3854            match connection.driver {
3855                Driver::PostgresSync => {
3856                    #[cfg(feature = "postgres-sync")]
3857                    {
3858                        introspect_postgres_sync(creds)
3859                    }
3860                    #[cfg(not(feature = "postgres-sync"))]
3861                    {
3862                        Err(CliError::MissingDriver {
3863                            dialect: "PostgreSQL",
3864                            feature: "postgres-sync",
3865                        })
3866                    }
3867                }
3868                Driver::TokioPostgres => {
3869                    #[cfg(feature = "tokio-postgres")]
3870                    {
3871                        introspect_postgres_async(creds)
3872                    }
3873                    #[cfg(not(feature = "tokio-postgres"))]
3874                    {
3875                        Err(CliError::MissingDriver {
3876                            dialect: "PostgreSQL",
3877                            feature: "tokio-postgres",
3878                        })
3879                    }
3880                }
3881                _ => Err(connection_driver_mismatch(connection)),
3882            }
3883        }
3884
3885        _ => Err(CliError::Other(
3886            "PostgreSQL introspection requires postgres credentials".into(),
3887        )),
3888    }
3889}
3890
3891// ============================================================================
3892// SQLite Introspection (rusqlite)
3893// ============================================================================
3894
3895#[cfg(any(feature = "rusqlite", feature = "libsql", feature = "turso"))]
3896struct SqliteRawData {
3897    tables: Vec<(String, Option<String>)>,
3898    raw_columns: Vec<drizzle_migrations::sqlite::introspect::RawColumnInfo>,
3899    all_indexes: Vec<drizzle_migrations::sqlite::introspect::RawIndexInfo>,
3900    all_index_columns: Vec<drizzle_migrations::sqlite::introspect::RawIndexColumn>,
3901    all_fks: Vec<drizzle_migrations::sqlite::introspect::RawForeignKey>,
3902    all_views: Vec<drizzle_migrations::sqlite::introspect::RawViewInfo>,
3903    index_sql: Vec<(String, String)>,
3904}
3905
3906#[cfg(feature = "rusqlite")]
3907impl SqliteRawData {
3908    const fn empty() -> Self {
3909        Self {
3910            tables: Vec::new(),
3911            raw_columns: Vec::new(),
3912            all_indexes: Vec::new(),
3913            all_index_columns: Vec::new(),
3914            all_fks: Vec::new(),
3915            all_views: Vec::new(),
3916            index_sql: Vec::new(),
3917        }
3918    }
3919}
3920
3921#[cfg(feature = "rusqlite")]
3922fn query_rusqlite_tables_and_columns(
3923    conn: &rusqlite::Connection,
3924    raw: &mut SqliteRawData,
3925) -> Result<(), CliError> {
3926    use drizzle_migrations::sqlite::introspect::{RawColumnInfo, queries};
3927
3928    let mut tables_stmt = conn
3929        .prepare(queries::TABLES_QUERY)
3930        .map_err(|e| CliError::Other(format!("Failed to prepare tables query: {e}")))?;
3931
3932    raw.tables = tables_stmt
3933        .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
3934        .map_err(|e| CliError::Other(e.to_string()))?
3935        .collect::<Result<Vec<_>, _>>()
3936        .map_err(|e| CliError::Other(e.to_string()))?;
3937
3938    let mut columns_stmt = conn
3939        .prepare(queries::COLUMNS_QUERY)
3940        .map_err(|e| CliError::Other(format!("Failed to prepare columns query: {e}")))?;
3941
3942    raw.raw_columns = columns_stmt
3943        .query_map([], |row| {
3944            Ok(RawColumnInfo {
3945                table: row.get(0)?,
3946                cid: row.get(1)?,
3947                name: row.get(2)?,
3948                column_type: row.get(3)?,
3949                not_null: row.get(4)?,
3950                default_value: row.get(5)?,
3951                pk: row.get(6)?,
3952                hidden: row.get(7)?,
3953                sql: row.get(8)?,
3954            })
3955        })
3956        .map_err(|e| CliError::Other(e.to_string()))?
3957        .collect::<Result<Vec<_>, _>>()
3958        .map_err(|e| CliError::Other(e.to_string()))?;
3959
3960    Ok(())
3961}
3962
3963#[cfg(feature = "rusqlite")]
3964fn query_rusqlite_indexes_and_foreign_keys(
3965    conn: &rusqlite::Connection,
3966    raw: &mut SqliteRawData,
3967) -> Result<(), CliError> {
3968    use drizzle_migrations::sqlite::introspect::{
3969        RawForeignKey, RawIndexColumn, RawIndexInfo, queries,
3970    };
3971
3972    let mut indexes_stmt = conn
3973        .prepare(queries::INDEXES_QUERY)
3974        .map_err(|e| CliError::Other(e.to_string()))?;
3975    raw.all_indexes = indexes_stmt
3976        .query_map([], |row| {
3977            Ok(RawIndexInfo {
3978                table: row.get(0)?,
3979                name: row.get(1)?,
3980                unique: row.get::<_, i32>(2)? != 0,
3981                origin: row.get(3)?,
3982                partial: row.get::<_, i32>(4)? != 0,
3983            })
3984        })
3985        .map_err(|e| CliError::Other(e.to_string()))?
3986        .collect::<Result<Vec<_>, _>>()
3987        .map_err(|e| CliError::Other(e.to_string()))?;
3988
3989    let mut columns_stmt = conn
3990        .prepare(queries::INDEX_COLUMNS_QUERY)
3991        .map_err(|e| CliError::Other(e.to_string()))?;
3992    raw.all_index_columns = columns_stmt
3993        .query_map([], |row| {
3994            Ok(RawIndexColumn {
3995                index_name: row.get(0)?,
3996                seqno: row.get(1)?,
3997                cid: row.get(2)?,
3998                name: row.get(3)?,
3999                desc: row.get::<_, i32>(4)? != 0,
4000                coll: row.get(5)?,
4001                key: row.get::<_, i32>(6)? != 0,
4002            })
4003        })
4004        .map_err(|e| CliError::Other(e.to_string()))?
4005        .collect::<Result<Vec<_>, _>>()
4006        .map_err(|e| CliError::Other(e.to_string()))?;
4007
4008    let mut foreign_keys_stmt = conn
4009        .prepare(queries::FOREIGN_KEYS_QUERY)
4010        .map_err(|e| CliError::Other(e.to_string()))?;
4011    raw.all_fks = foreign_keys_stmt
4012        .query_map([], |row| {
4013            Ok(RawForeignKey {
4014                table: row.get(0)?,
4015                id: row.get(1)?,
4016                seq: row.get(2)?,
4017                to_table: row.get(3)?,
4018                from_column: row.get(4)?,
4019                to_column: row.get(5)?,
4020                on_update: row.get(6)?,
4021                on_delete: row.get(7)?,
4022                r#match: row.get(8)?,
4023            })
4024        })
4025        .map_err(|e| CliError::Other(e.to_string()))?
4026        .collect::<Result<Vec<_>, _>>()
4027        .map_err(|e| CliError::Other(e.to_string()))?;
4028
4029    Ok(())
4030}
4031
4032#[cfg(feature = "rusqlite")]
4033fn query_rusqlite_views_and_view_columns(
4034    conn: &rusqlite::Connection,
4035    raw: &mut SqliteRawData,
4036) -> Result<(), CliError> {
4037    use drizzle_migrations::sqlite::introspect::{RawColumnInfo, RawViewInfo, queries};
4038
4039    let mut views_stmt = conn
4040        .prepare(queries::VIEWS_QUERY)
4041        .map_err(|e| CliError::Other(e.to_string()))?;
4042    let view_iter = views_stmt
4043        .query_map([], |row| {
4044            Ok(RawViewInfo {
4045                name: row.get(0)?,
4046                sql: row.get(1)?,
4047            })
4048        })
4049        .map_err(|e| CliError::Other(e.to_string()))?;
4050    raw.all_views.extend(
4051        view_iter
4052            .collect::<Result<Vec<_>, _>>()
4053            .map_err(|e| CliError::Other(e.to_string()))?,
4054    );
4055
4056    let mut view_cols_stmt = conn
4057        .prepare(queries::VIEW_COLUMNS_QUERY)
4058        .map_err(|e| CliError::Other(e.to_string()))?;
4059    let col_iter = view_cols_stmt
4060        .query_map([], |row| {
4061            Ok(RawColumnInfo {
4062                table: row.get(0)?,
4063                cid: row.get(1)?,
4064                name: row.get(2)?,
4065                column_type: row.get(3)?,
4066                not_null: row.get::<_, i32>(4)? != 0,
4067                default_value: row.get(5)?,
4068                pk: row.get(6)?,
4069                hidden: row.get(7)?,
4070                sql: row.get(8)?,
4071            })
4072        })
4073        .map_err(|e| CliError::Other(e.to_string()))?;
4074    raw.raw_columns.extend(
4075        col_iter
4076            .collect::<Result<Vec<_>, _>>()
4077            .map_err(|e| CliError::Other(e.to_string()))?,
4078    );
4079    Ok(())
4080}
4081
4082#[cfg(feature = "rusqlite")]
4083fn query_rusqlite_raw(conn: &rusqlite::Connection) -> Result<SqliteRawData, CliError> {
4084    use drizzle_migrations::sqlite::introspect::queries;
4085
4086    let mut raw = SqliteRawData::empty();
4087    query_rusqlite_tables_and_columns(conn, &mut raw)?;
4088    query_rusqlite_indexes_and_foreign_keys(conn, &mut raw)?;
4089    query_rusqlite_views_and_view_columns(conn, &mut raw)?;
4090
4091    let mut index_sql_stmt = conn
4092        .prepare(queries::INDEX_SQL_QUERY)
4093        .map_err(|e| CliError::Other(format!("Failed to prepare index sql query: {e}")))?;
4094    raw.index_sql = index_sql_stmt
4095        .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
4096        .map_err(|e| CliError::Other(e.to_string()))?
4097        .collect::<Result<Vec<_>, _>>()
4098        .map_err(|e| CliError::Other(e.to_string()))?;
4099
4100    Ok(raw)
4101}
4102
4103#[cfg(any(feature = "rusqlite", feature = "libsql", feature = "turso"))]
4104fn build_sqlite_ddl(raw: SqliteRawData) -> drizzle_migrations::sqlite::SQLiteDDL {
4105    drizzle_migrations::sqlite::introspect::assemble_ddl(
4106        drizzle_migrations::sqlite::introspect::RawIntrospection {
4107            tables: raw.tables,
4108            columns: raw.raw_columns,
4109            indexes: raw.all_indexes,
4110            index_columns: raw.all_index_columns,
4111            foreign_keys: raw.all_fks,
4112            views: raw.all_views,
4113            index_sql: raw.index_sql,
4114        },
4115    )
4116}
4117
4118#[cfg(feature = "rusqlite")]
4119fn introspect_rusqlite(path: &str) -> Result<IntrospectResult, CliError> {
4120    use drizzle_migrations::sqlite::codegen::{CodegenOptions, FieldCasing, generate_rust_schema};
4121
4122    let conn = rusqlite::Connection::open(path).map_err(|e| {
4123        CliError::ConnectionError(format!("Failed to open SQLite database '{path}': {e}"))
4124    })?;
4125
4126    let raw = query_rusqlite_raw(&conn)?;
4127    let ddl = build_sqlite_ddl(raw);
4128
4129    let options = CodegenOptions {
4130        module_doc: Some(format!("Schema introspected from {path}")),
4131        include_schema: true,
4132        schema_name: "Schema".to_string(),
4133        use_pub: true,
4134        field_casing: FieldCasing::default(),
4135    };
4136
4137    let generated = generate_rust_schema(&ddl, &options);
4138
4139    let mut sqlite_snapshot = drizzle_migrations::sqlite::SQLiteSnapshot::new();
4140    for entity in ddl.to_entities() {
4141        sqlite_snapshot.add_entity(entity);
4142    }
4143    let snapshot = Snapshot::Sqlite(sqlite_snapshot);
4144
4145    Ok(IntrospectResult {
4146        schema_code: generated.code,
4147        table_count: generated.tables.len(),
4148        index_count: generated.indexes.len(),
4149        view_count: ddl.views.len(),
4150        warnings: generated.warnings,
4151        mysql_catalog_defaults: None,
4152        snapshot,
4153        snapshot_path: std::path::PathBuf::new(),
4154    })
4155}
4156
4157// ============================================================================
4158// LibSQL Introspection (local)
4159// ============================================================================
4160
4161#[cfg(feature = "libsql")]
4162fn introspect_libsql_local(path: &str) -> Result<IntrospectResult, CliError> {
4163    let rt = tokio::runtime::Builder::new_current_thread()
4164        .enable_all()
4165        .build()
4166        .map_err(|e| CliError::Other(format!("Failed to create async runtime: {e}")))?;
4167
4168    rt.block_on(introspect_libsql_inner(path, None))
4169}
4170
4171#[cfg(any(feature = "libsql", feature = "turso"))]
4172async fn query_libsql_tables_and_columns(
4173    conn: &libsql::Connection,
4174) -> Result<
4175    (
4176        Vec<(String, Option<String>)>,
4177        Vec<drizzle_migrations::sqlite::introspect::RawColumnInfo>,
4178    ),
4179    CliError,
4180> {
4181    use drizzle_migrations::sqlite::introspect::{RawColumnInfo, queries};
4182
4183    let mut tables_rows = conn
4184        .query(queries::TABLES_QUERY, ())
4185        .await
4186        .map_err(|e| CliError::Other(format!("Failed to query tables: {e}")))?;
4187
4188    let mut tables: Vec<(String, Option<String>)> = Vec::new();
4189    while let Some(row) = tables_rows
4190        .next()
4191        .await
4192        .map_err(|e| CliError::Other(e.to_string()))?
4193    {
4194        let name: String = row.get(0).map_err(|e| CliError::Other(e.to_string()))?;
4195        let sql: Option<String> = row.get(1).ok();
4196        tables.push((name, sql));
4197    }
4198
4199    let mut columns_rows = conn
4200        .query(queries::COLUMNS_QUERY, ())
4201        .await
4202        .map_err(|e| CliError::Other(format!("Failed to query columns: {e}")))?;
4203
4204    let mut raw_columns: Vec<RawColumnInfo> = Vec::new();
4205    while let Some(row) = columns_rows
4206        .next()
4207        .await
4208        .map_err(|e| CliError::Other(e.to_string()))?
4209    {
4210        raw_columns.push(RawColumnInfo {
4211            table: row.get(0).map_err(|e| CliError::Other(e.to_string()))?,
4212            cid: row.get(1).map_err(|e| CliError::Other(e.to_string()))?,
4213            name: row.get(2).map_err(|e| CliError::Other(e.to_string()))?,
4214            column_type: row.get(3).map_err(|e| CliError::Other(e.to_string()))?,
4215            not_null: row
4216                .get::<i32>(4)
4217                .map_err(|e| CliError::Other(e.to_string()))?
4218                != 0,
4219            default_value: row.get(5).ok(),
4220            pk: row.get(6).map_err(|e| CliError::Other(e.to_string()))?,
4221            hidden: row.get(7).map_err(|e| CliError::Other(e.to_string()))?,
4222            sql: row.get(8).ok(),
4223        });
4224    }
4225
4226    Ok((tables, raw_columns))
4227}
4228
4229#[cfg(any(feature = "libsql", feature = "turso"))]
4230async fn query_libsql_indexes_and_foreign_keys(
4231    conn: &libsql::Connection,
4232) -> Result<
4233    (
4234        Vec<drizzle_migrations::sqlite::introspect::RawIndexInfo>,
4235        Vec<drizzle_migrations::sqlite::introspect::RawIndexColumn>,
4236        Vec<drizzle_migrations::sqlite::introspect::RawForeignKey>,
4237    ),
4238    CliError,
4239> {
4240    use drizzle_migrations::sqlite::introspect::{
4241        RawForeignKey, RawIndexColumn, RawIndexInfo, queries,
4242    };
4243
4244    let mut all_indexes = Vec::<RawIndexInfo>::new();
4245    let mut index_rows = conn
4246        .query(queries::INDEXES_QUERY, ())
4247        .await
4248        .map_err(|e| CliError::Other(e.to_string()))?;
4249    while let Some(row) = index_rows
4250        .next()
4251        .await
4252        .map_err(|e| CliError::Other(e.to_string()))?
4253    {
4254        all_indexes.push(RawIndexInfo {
4255            table: row.get(0).map_err(|e| CliError::Other(e.to_string()))?,
4256            name: row.get(1).map_err(|e| CliError::Other(e.to_string()))?,
4257            unique: row
4258                .get::<i32>(2)
4259                .map_err(|e| CliError::Other(e.to_string()))?
4260                != 0,
4261            origin: row.get(3).map_err(|e| CliError::Other(e.to_string()))?,
4262            partial: row
4263                .get::<i32>(4)
4264                .map_err(|e| CliError::Other(e.to_string()))?
4265                != 0,
4266        });
4267    }
4268
4269    let mut all_index_columns = Vec::<RawIndexColumn>::new();
4270    let mut column_rows = conn
4271        .query(queries::INDEX_COLUMNS_QUERY, ())
4272        .await
4273        .map_err(|e| CliError::Other(e.to_string()))?;
4274    while let Some(row) = column_rows
4275        .next()
4276        .await
4277        .map_err(|e| CliError::Other(e.to_string()))?
4278    {
4279        all_index_columns.push(RawIndexColumn {
4280            index_name: row.get(0).map_err(|e| CliError::Other(e.to_string()))?,
4281            seqno: row.get(1).map_err(|e| CliError::Other(e.to_string()))?,
4282            cid: row.get(2).map_err(|e| CliError::Other(e.to_string()))?,
4283            name: row.get(3).ok(),
4284            desc: row
4285                .get::<i32>(4)
4286                .map_err(|e| CliError::Other(e.to_string()))?
4287                != 0,
4288            coll: row.get(5).map_err(|e| CliError::Other(e.to_string()))?,
4289            key: row
4290                .get::<i32>(6)
4291                .map_err(|e| CliError::Other(e.to_string()))?
4292                != 0,
4293        });
4294    }
4295
4296    let mut all_fks = Vec::<RawForeignKey>::new();
4297    let mut foreign_key_rows = conn
4298        .query(queries::FOREIGN_KEYS_QUERY, ())
4299        .await
4300        .map_err(|e| CliError::Other(e.to_string()))?;
4301    while let Some(row) = foreign_key_rows
4302        .next()
4303        .await
4304        .map_err(|e| CliError::Other(e.to_string()))?
4305    {
4306        all_fks.push(RawForeignKey {
4307            table: row.get(0).map_err(|e| CliError::Other(e.to_string()))?,
4308            id: row.get(1).map_err(|e| CliError::Other(e.to_string()))?,
4309            seq: row.get(2).map_err(|e| CliError::Other(e.to_string()))?,
4310            to_table: row.get(3).map_err(|e| CliError::Other(e.to_string()))?,
4311            from_column: row.get(4).map_err(|e| CliError::Other(e.to_string()))?,
4312            to_column: row.get(5).map_err(|e| CliError::Other(e.to_string()))?,
4313            on_update: row.get(6).map_err(|e| CliError::Other(e.to_string()))?,
4314            on_delete: row.get(7).map_err(|e| CliError::Other(e.to_string()))?,
4315            r#match: row.get(8).map_err(|e| CliError::Other(e.to_string()))?,
4316        });
4317    }
4318
4319    Ok((all_indexes, all_index_columns, all_fks))
4320}
4321
4322#[cfg(any(feature = "libsql", feature = "turso"))]
4323async fn query_libsql_views_and_view_columns(
4324    conn: &libsql::Connection,
4325    raw_columns: &mut Vec<drizzle_migrations::sqlite::introspect::RawColumnInfo>,
4326) -> Result<Vec<drizzle_migrations::sqlite::introspect::RawViewInfo>, CliError> {
4327    use drizzle_migrations::sqlite::introspect::{RawColumnInfo, RawViewInfo, queries};
4328
4329    let mut all_views: Vec<RawViewInfo> = Vec::new();
4330    let mut views_rows = conn
4331        .query(queries::VIEWS_QUERY, ())
4332        .await
4333        .map_err(|e| CliError::Other(e.to_string()))?;
4334    while let Some(row) = views_rows
4335        .next()
4336        .await
4337        .map_err(|e| CliError::Other(e.to_string()))?
4338    {
4339        let name: String = row.get(0).map_err(|e| CliError::Other(e.to_string()))?;
4340        let sql: String = row.get(1).map_err(|e| CliError::Other(e.to_string()))?;
4341        all_views.push(RawViewInfo { name, sql });
4342    }
4343
4344    let mut view_cols_rows = conn
4345        .query(queries::VIEW_COLUMNS_QUERY, ())
4346        .await
4347        .map_err(|e| CliError::Other(e.to_string()))?;
4348    while let Some(row) = view_cols_rows
4349        .next()
4350        .await
4351        .map_err(|e| CliError::Other(e.to_string()))?
4352    {
4353        raw_columns.push(RawColumnInfo {
4354            table: row.get(0).map_err(|e| CliError::Other(e.to_string()))?,
4355            cid: row.get(1).map_err(|e| CliError::Other(e.to_string()))?,
4356            name: row.get(2).map_err(|e| CliError::Other(e.to_string()))?,
4357            column_type: row.get(3).map_err(|e| CliError::Other(e.to_string()))?,
4358            not_null: row
4359                .get::<i32>(4)
4360                .map_err(|e| CliError::Other(e.to_string()))?
4361                != 0,
4362            default_value: row.get(5).ok(),
4363            pk: row.get(6).map_err(|e| CliError::Other(e.to_string()))?,
4364            hidden: row.get(7).map_err(|e| CliError::Other(e.to_string()))?,
4365            sql: row.get(8).ok(),
4366        });
4367    }
4368    Ok(all_views)
4369}
4370
4371#[cfg(any(feature = "libsql", feature = "turso"))]
4372async fn query_libsql_raw(conn: &libsql::Connection) -> Result<SqliteRawData, CliError> {
4373    use drizzle_migrations::sqlite::introspect::queries;
4374
4375    let (tables, mut raw_columns) = query_libsql_tables_and_columns(conn).await?;
4376    let (all_indexes, all_index_columns, all_fks) =
4377        query_libsql_indexes_and_foreign_keys(conn).await?;
4378    let all_views = query_libsql_views_and_view_columns(conn, &mut raw_columns).await?;
4379
4380    let mut index_sql = Vec::new();
4381    let mut rows = conn
4382        .query(queries::INDEX_SQL_QUERY, ())
4383        .await
4384        .map_err(|e| CliError::Other(e.to_string()))?;
4385    while let Some(row) = rows
4386        .next()
4387        .await
4388        .map_err(|e| CliError::Other(e.to_string()))?
4389    {
4390        let name: String = row.get(0).map_err(|e| CliError::Other(e.to_string()))?;
4391        let sql: String = row.get(1).map_err(|e| CliError::Other(e.to_string()))?;
4392        index_sql.push((name, sql));
4393    }
4394
4395    Ok(SqliteRawData {
4396        tables,
4397        raw_columns,
4398        all_indexes,
4399        all_index_columns,
4400        all_fks,
4401        all_views,
4402        index_sql,
4403    })
4404}
4405
4406#[cfg(feature = "libsql")]
4407async fn introspect_libsql_inner(
4408    path: &str,
4409    _auth_token: Option<&str>,
4410) -> Result<IntrospectResult, CliError> {
4411    use drizzle_migrations::sqlite::codegen::{CodegenOptions, FieldCasing, generate_rust_schema};
4412
4413    let db = libsql::Builder::new_local(path)
4414        .build()
4415        .await
4416        .map_err(|e| {
4417            CliError::ConnectionError(format!("Failed to open LibSQL database '{path}': {e}"))
4418        })?;
4419
4420    let conn = db
4421        .connect()
4422        .map_err(|e| CliError::ConnectionError(e.to_string()))?;
4423
4424    let raw = query_libsql_raw(&conn).await?;
4425    let ddl = build_sqlite_ddl(raw);
4426
4427    let options = CodegenOptions {
4428        module_doc: Some(format!("Schema introspected from {path}")),
4429        include_schema: true,
4430        schema_name: "Schema".to_string(),
4431        use_pub: true,
4432        field_casing: FieldCasing::default(),
4433    };
4434
4435    let generated = generate_rust_schema(&ddl, &options);
4436
4437    let mut sqlite_snapshot = drizzle_migrations::sqlite::SQLiteSnapshot::new();
4438    for entity in ddl.to_entities() {
4439        sqlite_snapshot.add_entity(entity);
4440    }
4441    let snapshot = Snapshot::Sqlite(sqlite_snapshot);
4442
4443    Ok(IntrospectResult {
4444        schema_code: generated.code,
4445        table_count: generated.tables.len(),
4446        index_count: generated.indexes.len(),
4447        view_count: ddl.views.len(),
4448        warnings: generated.warnings,
4449        mysql_catalog_defaults: None,
4450        snapshot,
4451        snapshot_path: std::path::PathBuf::new(),
4452    })
4453}
4454
4455// ============================================================================
4456// Turso Introspection (remote)
4457// ============================================================================
4458
4459#[cfg(feature = "turso")]
4460fn introspect_turso(url: &str, auth_token: Option<&str>) -> Result<IntrospectResult, CliError> {
4461    let rt = tokio::runtime::Builder::new_current_thread()
4462        .enable_all()
4463        .build()
4464        .map_err(|e| CliError::Other(format!("Failed to create async runtime: {e}")))?;
4465
4466    rt.block_on(introspect_turso_inner(url, auth_token))
4467}
4468
4469#[cfg(feature = "turso")]
4470async fn introspect_turso_inner(
4471    url: &str,
4472    auth_token: Option<&str>,
4473) -> Result<IntrospectResult, CliError> {
4474    use drizzle_migrations::sqlite::codegen::{CodegenOptions, FieldCasing, generate_rust_schema};
4475
4476    let builder =
4477        libsql::Builder::new_remote(url.to_string(), auth_token.unwrap_or("").to_string());
4478
4479    let db = builder.build().await.map_err(|e| {
4480        CliError::ConnectionError(format!("Failed to connect to Turso '{url}': {e}"))
4481    })?;
4482
4483    let conn = db
4484        .connect()
4485        .map_err(|e| CliError::ConnectionError(e.to_string()))?;
4486
4487    let raw = query_libsql_raw(&conn).await?;
4488    let ddl = build_sqlite_ddl(raw);
4489
4490    let options = CodegenOptions {
4491        module_doc: Some(format!("Schema introspected from Turso: {url}")),
4492        include_schema: true,
4493        schema_name: "Schema".to_string(),
4494        use_pub: true,
4495        field_casing: FieldCasing::default(),
4496    };
4497
4498    let generated = generate_rust_schema(&ddl, &options);
4499
4500    let mut sqlite_snapshot = drizzle_migrations::sqlite::SQLiteSnapshot::new();
4501    for entity in ddl.to_entities() {
4502        sqlite_snapshot.add_entity(entity);
4503    }
4504    let snapshot = Snapshot::Sqlite(sqlite_snapshot);
4505
4506    Ok(IntrospectResult {
4507        schema_code: generated.code,
4508        table_count: generated.tables.len(),
4509        index_count: generated.indexes.len(),
4510        view_count: ddl.views.len(),
4511        warnings: generated.warnings,
4512        mysql_catalog_defaults: None,
4513        snapshot,
4514        snapshot_path: std::path::PathBuf::new(),
4515    })
4516}
4517
4518// ============================================================================
4519// PostgreSQL Introspection
4520// ============================================================================
4521
4522#[cfg(feature = "postgres-sync")]
4523fn introspect_postgres_sync(creds: &PostgresCreds) -> Result<IntrospectResult, CliError> {
4524    let mut client = connect_postgres_sync(creds)?;
4525
4526    let raw = query_postgres_sync_raw(&mut client)?;
4527    let ddl = build_postgres_ddl(raw);
4528
4529    Ok(finalize_postgres_introspection(
4530        &ddl,
4531        "configured PostgreSQL database",
4532    ))
4533}
4534
4535#[cfg(any(feature = "postgres-sync", feature = "tokio-postgres"))]
4536impl PostgresRawData {
4537    const fn empty() -> Self {
4538        Self {
4539            schemas: Vec::new(),
4540            tables: Vec::new(),
4541            columns: Vec::new(),
4542            enums: Vec::new(),
4543            sequences: Vec::new(),
4544            views: Vec::new(),
4545            indexes: Vec::new(),
4546            foreign_keys: Vec::new(),
4547            primary_keys: Vec::new(),
4548            uniques: Vec::new(),
4549            checks: Vec::new(),
4550            roles: Vec::new(),
4551            policies: Vec::new(),
4552        }
4553    }
4554}
4555
4556#[cfg(feature = "postgres-sync")]
4557fn query_postgres_sync_raw(client: &mut postgres::Client) -> Result<PostgresRawData, CliError> {
4558    let mut raw = PostgresRawData::empty();
4559    query_pg_sync_core(client, &mut raw)?;
4560    query_pg_sync_codegen_meta(client, &mut raw)?;
4561    query_pg_sync_constraints(client, &mut raw)?;
4562    query_pg_sync_security(client, &mut raw)?;
4563    Ok(raw)
4564}
4565
4566#[cfg(feature = "postgres-sync")]
4567fn query_pg_sync_core(
4568    client: &mut postgres::Client,
4569    raw: &mut PostgresRawData,
4570) -> Result<(), CliError> {
4571    use drizzle_migrations::postgres::introspect::{RawColumnInfo, RawTableInfo, queries};
4572
4573    raw.schemas = client
4574        .query(queries::SCHEMAS_QUERY, &[])
4575        .map_err(|e| CliError::Other(format!("Failed to query schemas: {e}")))?
4576        .into_iter()
4577        .map(|row| RawSchemaInfo {
4578            name: row.get::<_, String>(0),
4579        })
4580        .collect();
4581
4582    raw.tables = client
4583        .query(queries::TABLES_QUERY, &[])
4584        .map_err(|e| CliError::Other(format!("Failed to query tables: {e}")))?
4585        .into_iter()
4586        .map(|row| RawTableInfo {
4587            schema: row.get::<_, String>(0),
4588            name: row.get::<_, String>(1),
4589            is_rls_enabled: row.get::<_, bool>(2),
4590            is_unlogged: row.get::<_, bool>(3),
4591            is_temporary: row.get::<_, bool>(4),
4592            tablespace: row.get::<_, Option<String>>(5),
4593            comment: row.get::<_, Option<String>>(6),
4594        })
4595        .collect();
4596
4597    raw.columns = client
4598        .query(queries::COLUMNS_QUERY, &[])
4599        .map_err(|e| CliError::Other(format!("Failed to query columns: {e}")))?
4600        .into_iter()
4601        .map(|row| RawColumnInfo {
4602            schema: row.get::<_, String>(0),
4603            table: row.get::<_, String>(1),
4604            name: row.get::<_, String>(2),
4605            column_type: row.get::<_, String>(3),
4606            type_schema: row.get::<_, Option<String>>(4),
4607            not_null: row.get::<_, bool>(5),
4608            default_value: row.get::<_, Option<String>>(6),
4609            is_identity: row.get::<_, bool>(7),
4610            identity_type: row.get::<_, Option<String>>(8),
4611            is_generated: row.get::<_, bool>(9),
4612            generated_expression: row.get::<_, Option<String>>(10),
4613            generated_stored: row.get::<_, bool>(11),
4614            dimensions: row.get::<_, Option<i32>>(12),
4615            comment: row.get::<_, Option<String>>(13),
4616            ordinal_position: row.get::<_, i32>(14),
4617        })
4618        .collect();
4619
4620    Ok(())
4621}
4622
4623#[cfg(feature = "postgres-sync")]
4624fn query_pg_sync_codegen_meta(
4625    client: &mut postgres::Client,
4626    raw: &mut PostgresRawData,
4627) -> Result<(), CliError> {
4628    use drizzle_migrations::postgres::introspect::{
4629        RawEnumInfo, RawSequenceInfo, RawViewInfo, queries,
4630    };
4631
4632    raw.enums = client
4633        .query(queries::ENUMS_QUERY, &[])
4634        .map_err(|e| CliError::Other(format!("Failed to query enums: {e}")))?
4635        .into_iter()
4636        .map(|row| RawEnumInfo {
4637            schema: row.get::<_, String>(0),
4638            name: row.get::<_, String>(1),
4639            values: row.get::<_, Vec<String>>(2),
4640        })
4641        .collect();
4642
4643    raw.sequences = client
4644        .query(queries::SEQUENCES_QUERY, &[])
4645        .map_err(|e| CliError::Other(format!("Failed to query sequences: {e}")))?
4646        .into_iter()
4647        .map(|row| RawSequenceInfo {
4648            schema: row.get::<_, String>(0),
4649            name: row.get::<_, String>(1),
4650            data_type: row.get::<_, Option<String>>(2),
4651            start_value: row.get::<_, Option<String>>(3),
4652            min_value: row.get::<_, Option<String>>(4),
4653            max_value: row.get::<_, Option<String>>(5),
4654            increment: row.get::<_, Option<String>>(6),
4655            cycle: row.get::<_, Option<bool>>(7),
4656            cache_value: row.get::<_, Option<String>>(8),
4657            owned_by: row.get::<_, Option<String>>(9),
4658        })
4659        .collect();
4660
4661    let view_schema_filters: Option<Vec<String>> = None;
4662    raw.views = client
4663        .query(queries::VIEWS_QUERY, &[&view_schema_filters])
4664        .map_err(|e| CliError::Other(format!("Failed to query views: {e}")))?
4665        .into_iter()
4666        .map(|row| RawViewInfo {
4667            schema: row.get::<_, String>(0),
4668            name: row.get::<_, String>(1),
4669            definition: row.get::<_, String>(2),
4670            is_materialized: row.get::<_, bool>(3),
4671        })
4672        .collect();
4673
4674    Ok(())
4675}
4676
4677#[cfg(feature = "postgres-sync")]
4678fn query_pg_sync_constraints(
4679    client: &mut postgres::Client,
4680    raw: &mut PostgresRawData,
4681) -> Result<(), CliError> {
4682    use drizzle_migrations::postgres::introspect::{
4683        RawCheckInfo, RawForeignKeyInfo, RawIndexInfo, RawPrimaryKeyInfo, RawUniqueInfo,
4684        action_code_to_string, parse_index_columns, queries,
4685    };
4686
4687    raw.indexes = client
4688        .query(queries::INDEXES_QUERY, &[])
4689        .map_err(|e| CliError::Other(format!("Failed to query indexes: {e}")))?
4690        .into_iter()
4691        .map(|row| {
4692            let cols: Vec<String> = row.get(6);
4693            RawIndexInfo {
4694                schema: row.get::<_, String>(0),
4695                table: row.get::<_, String>(1),
4696                name: row.get::<_, String>(2),
4697                is_unique: row.get::<_, bool>(3),
4698                is_primary: row.get::<_, bool>(4),
4699                method: row.get::<_, String>(5),
4700                columns: parse_index_columns(cols),
4701                where_clause: row.get::<_, Option<String>>(7),
4702                concurrent: false,
4703            }
4704        })
4705        .collect();
4706
4707    raw.foreign_keys = client
4708        .query(queries::FOREIGN_KEYS_QUERY, &[])
4709        .map_err(|e| CliError::Other(format!("Failed to query foreign keys: {e}")))?
4710        .into_iter()
4711        .map(|row| RawForeignKeyInfo {
4712            schema: row.get::<_, String>(0),
4713            table: row.get::<_, String>(1),
4714            name: row.get::<_, String>(2),
4715            columns: row.get::<_, Vec<String>>(3),
4716            schema_to: row.get::<_, String>(4),
4717            table_to: row.get::<_, String>(5),
4718            columns_to: row.get::<_, Vec<String>>(6),
4719            on_update: action_code_to_string(&row.get::<_, String>(7)),
4720            on_delete: action_code_to_string(&row.get::<_, String>(8)),
4721            deferrable: row.get::<_, bool>(9),
4722            initially_deferred: row.get::<_, bool>(10),
4723        })
4724        .collect();
4725
4726    raw.primary_keys = client
4727        .query(queries::PRIMARY_KEYS_QUERY, &[])
4728        .map_err(|e| CliError::Other(format!("Failed to query primary keys: {e}")))?
4729        .into_iter()
4730        .map(|row| RawPrimaryKeyInfo {
4731            schema: row.get::<_, String>(0),
4732            table: row.get::<_, String>(1),
4733            name: row.get::<_, String>(2),
4734            columns: row.get::<_, Vec<String>>(3),
4735        })
4736        .collect();
4737
4738    raw.uniques = client
4739        .query(queries::UNIQUES_QUERY, &[])
4740        .map_err(|e| CliError::Other(format!("Failed to query unique constraints: {e}")))?
4741        .into_iter()
4742        .map(|row| RawUniqueInfo {
4743            schema: row.get::<_, String>(0),
4744            table: row.get::<_, String>(1),
4745            name: row.get::<_, String>(2),
4746            columns: row.get::<_, Vec<String>>(3),
4747            nulls_not_distinct: row.get::<_, bool>(4),
4748            deferrable: row.get::<_, bool>(5),
4749            initially_deferred: row.get::<_, bool>(6),
4750        })
4751        .collect();
4752
4753    raw.checks = client
4754        .query(queries::CHECKS_QUERY, &[])
4755        .map_err(|e| CliError::Other(format!("Failed to query check constraints: {e}")))?
4756        .into_iter()
4757        .map(|row| RawCheckInfo {
4758            schema: row.get::<_, String>(0),
4759            table: row.get::<_, String>(1),
4760            name: row.get::<_, String>(2),
4761            expression: row.get::<_, String>(3),
4762        })
4763        .collect();
4764
4765    Ok(())
4766}
4767
4768#[cfg(feature = "postgres-sync")]
4769fn query_pg_sync_security(
4770    client: &mut postgres::Client,
4771    raw: &mut PostgresRawData,
4772) -> Result<(), CliError> {
4773    use drizzle_migrations::postgres::introspect::{RawPolicyInfo, RawRoleInfo, queries};
4774
4775    raw.roles = client
4776        .query(queries::ROLES_QUERY, &[])
4777        .map_err(|e| CliError::Other(format!("Failed to query roles: {e}")))?
4778        .into_iter()
4779        .map(|row| RawRoleInfo {
4780            name: row.get::<_, String>(0),
4781            create_db: row.get::<_, bool>(1),
4782            create_role: row.get::<_, bool>(2),
4783            inherit: row.get::<_, bool>(3),
4784        })
4785        .collect();
4786
4787    raw.policies = client
4788        .query(queries::POLICIES_QUERY, &[])
4789        .map_err(|e| CliError::Other(format!("Failed to query policies: {e}")))?
4790        .into_iter()
4791        .map(|row| RawPolicyInfo {
4792            schema: row.get::<_, String>(0),
4793            table: row.get::<_, String>(1),
4794            name: row.get::<_, String>(2),
4795            as_clause: row.get::<_, String>(3),
4796            for_clause: row.get::<_, String>(4),
4797            to: row.get::<_, Vec<String>>(5),
4798            using: row.get::<_, Option<String>>(6),
4799            with_check: row.get::<_, Option<String>>(7),
4800        })
4801        .collect();
4802
4803    Ok(())
4804}
4805
4806#[cfg(feature = "tokio-postgres")]
4807fn introspect_postgres_async(creds: &PostgresCreds) -> Result<IntrospectResult, CliError> {
4808    let rt = tokio::runtime::Builder::new_current_thread()
4809        .enable_all()
4810        .build()
4811        .map_err(|e| CliError::Other(format!("Failed to create async runtime: {e}")))?;
4812
4813    rt.block_on(introspect_postgres_async_inner(creds))
4814}
4815
4816#[cfg(feature = "tokio-postgres")]
4817async fn introspect_postgres_async_inner(
4818    creds: &PostgresCreds,
4819) -> Result<IntrospectResult, CliError> {
4820    let client = connect_postgres_async(creds).await?;
4821
4822    let raw = query_postgres_async_raw(&client).await?;
4823    let ddl = build_postgres_ddl(raw);
4824
4825    Ok(finalize_postgres_introspection(
4826        &ddl,
4827        "configured PostgreSQL database",
4828    ))
4829}
4830
4831#[cfg(feature = "tokio-postgres")]
4832async fn query_postgres_async_raw(
4833    client: &tokio_postgres::Client,
4834) -> Result<PostgresRawData, CliError> {
4835    let mut raw = PostgresRawData::empty();
4836    query_pg_async_core(client, &mut raw).await?;
4837    query_pg_async_codegen_meta(client, &mut raw).await?;
4838    query_pg_async_constraints(client, &mut raw).await?;
4839    query_pg_async_security(client, &mut raw).await?;
4840    Ok(raw)
4841}
4842
4843#[cfg(feature = "tokio-postgres")]
4844async fn query_pg_async_core(
4845    client: &tokio_postgres::Client,
4846    raw: &mut PostgresRawData,
4847) -> Result<(), CliError> {
4848    use drizzle_migrations::postgres::introspect::{RawColumnInfo, RawTableInfo, queries};
4849
4850    raw.schemas = client
4851        .query(queries::SCHEMAS_QUERY, &[])
4852        .await
4853        .map_err(|e| CliError::Other(format!("Failed to query schemas: {e}")))?
4854        .into_iter()
4855        .map(|row| RawSchemaInfo {
4856            name: row.get::<_, String>(0),
4857        })
4858        .collect();
4859
4860    raw.tables = client
4861        .query(queries::TABLES_QUERY, &[])
4862        .await
4863        .map_err(|e| CliError::Other(format!("Failed to query tables: {e}")))?
4864        .into_iter()
4865        .map(|row| RawTableInfo {
4866            schema: row.get::<_, String>(0),
4867            name: row.get::<_, String>(1),
4868            is_rls_enabled: row.get::<_, bool>(2),
4869            is_unlogged: row.get::<_, bool>(3),
4870            is_temporary: row.get::<_, bool>(4),
4871            tablespace: row.get::<_, Option<String>>(5),
4872            comment: row.get::<_, Option<String>>(6),
4873        })
4874        .collect();
4875
4876    raw.columns = client
4877        .query(queries::COLUMNS_QUERY, &[])
4878        .await
4879        .map_err(|e| CliError::Other(format!("Failed to query columns: {e}")))?
4880        .into_iter()
4881        .map(|row| RawColumnInfo {
4882            schema: row.get::<_, String>(0),
4883            table: row.get::<_, String>(1),
4884            name: row.get::<_, String>(2),
4885            column_type: row.get::<_, String>(3),
4886            type_schema: row.get::<_, Option<String>>(4),
4887            not_null: row.get::<_, bool>(5),
4888            default_value: row.get::<_, Option<String>>(6),
4889            is_identity: row.get::<_, bool>(7),
4890            identity_type: row.get::<_, Option<String>>(8),
4891            is_generated: row.get::<_, bool>(9),
4892            generated_expression: row.get::<_, Option<String>>(10),
4893            generated_stored: row.get::<_, bool>(11),
4894            dimensions: row.get::<_, Option<i32>>(12),
4895            comment: row.get::<_, Option<String>>(13),
4896            ordinal_position: row.get::<_, i32>(14),
4897        })
4898        .collect();
4899
4900    Ok(())
4901}
4902
4903#[cfg(feature = "tokio-postgres")]
4904async fn query_pg_async_codegen_meta(
4905    client: &tokio_postgres::Client,
4906    raw: &mut PostgresRawData,
4907) -> Result<(), CliError> {
4908    use drizzle_migrations::postgres::introspect::{
4909        RawEnumInfo, RawSequenceInfo, RawViewInfo, queries,
4910    };
4911
4912    raw.enums = client
4913        .query(queries::ENUMS_QUERY, &[])
4914        .await
4915        .map_err(|e| CliError::Other(format!("Failed to query enums: {e}")))?
4916        .into_iter()
4917        .map(|row| RawEnumInfo {
4918            schema: row.get::<_, String>(0),
4919            name: row.get::<_, String>(1),
4920            values: row.get::<_, Vec<String>>(2),
4921        })
4922        .collect();
4923
4924    raw.sequences = client
4925        .query(queries::SEQUENCES_QUERY, &[])
4926        .await
4927        .map_err(|e| CliError::Other(format!("Failed to query sequences: {e}")))?
4928        .into_iter()
4929        .map(|row| RawSequenceInfo {
4930            schema: row.get::<_, String>(0),
4931            name: row.get::<_, String>(1),
4932            data_type: row.get::<_, Option<String>>(2),
4933            start_value: row.get::<_, Option<String>>(3),
4934            min_value: row.get::<_, Option<String>>(4),
4935            max_value: row.get::<_, Option<String>>(5),
4936            increment: row.get::<_, Option<String>>(6),
4937            cycle: row.get::<_, Option<bool>>(7),
4938            cache_value: row.get::<_, Option<String>>(8),
4939            owned_by: row.get::<_, Option<String>>(9),
4940        })
4941        .collect();
4942
4943    let view_schema_filters: Option<Vec<String>> = None;
4944    raw.views = client
4945        .query(queries::VIEWS_QUERY, &[&view_schema_filters])
4946        .await
4947        .map_err(|e| CliError::Other(format!("Failed to query views: {e}")))?
4948        .into_iter()
4949        .map(|row| RawViewInfo {
4950            schema: row.get::<_, String>(0),
4951            name: row.get::<_, String>(1),
4952            definition: row.get::<_, String>(2),
4953            is_materialized: row.get::<_, bool>(3),
4954        })
4955        .collect();
4956
4957    Ok(())
4958}
4959
4960#[cfg(feature = "tokio-postgres")]
4961async fn query_pg_async_constraints(
4962    client: &tokio_postgres::Client,
4963    raw: &mut PostgresRawData,
4964) -> Result<(), CliError> {
4965    use drizzle_migrations::postgres::introspect::{
4966        RawCheckInfo, RawForeignKeyInfo, RawIndexInfo, RawPrimaryKeyInfo, RawUniqueInfo,
4967        action_code_to_string, parse_index_columns, queries,
4968    };
4969
4970    raw.indexes = client
4971        .query(queries::INDEXES_QUERY, &[])
4972        .await
4973        .map_err(|e| CliError::Other(format!("Failed to query indexes: {e}")))?
4974        .into_iter()
4975        .map(|row| {
4976            let cols: Vec<String> = row.get(6);
4977            RawIndexInfo {
4978                schema: row.get::<_, String>(0),
4979                table: row.get::<_, String>(1),
4980                name: row.get::<_, String>(2),
4981                is_unique: row.get::<_, bool>(3),
4982                is_primary: row.get::<_, bool>(4),
4983                method: row.get::<_, String>(5),
4984                columns: parse_index_columns(cols),
4985                where_clause: row.get::<_, Option<String>>(7),
4986                concurrent: false,
4987            }
4988        })
4989        .collect();
4990
4991    raw.foreign_keys = client
4992        .query(queries::FOREIGN_KEYS_QUERY, &[])
4993        .await
4994        .map_err(|e| CliError::Other(format!("Failed to query foreign keys: {e}")))?
4995        .into_iter()
4996        .map(|row| RawForeignKeyInfo {
4997            schema: row.get::<_, String>(0),
4998            table: row.get::<_, String>(1),
4999            name: row.get::<_, String>(2),
5000            columns: row.get::<_, Vec<String>>(3),
5001            schema_to: row.get::<_, String>(4),
5002            table_to: row.get::<_, String>(5),
5003            columns_to: row.get::<_, Vec<String>>(6),
5004            on_update: action_code_to_string(&row.get::<_, String>(7)),
5005            on_delete: action_code_to_string(&row.get::<_, String>(8)),
5006            deferrable: row.get::<_, bool>(9),
5007            initially_deferred: row.get::<_, bool>(10),
5008        })
5009        .collect();
5010
5011    raw.primary_keys = client
5012        .query(queries::PRIMARY_KEYS_QUERY, &[])
5013        .await
5014        .map_err(|e| CliError::Other(format!("Failed to query primary keys: {e}")))?
5015        .into_iter()
5016        .map(|row| RawPrimaryKeyInfo {
5017            schema: row.get::<_, String>(0),
5018            table: row.get::<_, String>(1),
5019            name: row.get::<_, String>(2),
5020            columns: row.get::<_, Vec<String>>(3),
5021        })
5022        .collect();
5023
5024    raw.uniques = client
5025        .query(queries::UNIQUES_QUERY, &[])
5026        .await
5027        .map_err(|e| CliError::Other(format!("Failed to query unique constraints: {e}")))?
5028        .into_iter()
5029        .map(|row| RawUniqueInfo {
5030            schema: row.get::<_, String>(0),
5031            table: row.get::<_, String>(1),
5032            name: row.get::<_, String>(2),
5033            columns: row.get::<_, Vec<String>>(3),
5034            nulls_not_distinct: row.get::<_, bool>(4),
5035            deferrable: row.get::<_, bool>(5),
5036            initially_deferred: row.get::<_, bool>(6),
5037        })
5038        .collect();
5039
5040    raw.checks = client
5041        .query(queries::CHECKS_QUERY, &[])
5042        .await
5043        .map_err(|e| CliError::Other(format!("Failed to query check constraints: {e}")))?
5044        .into_iter()
5045        .map(|row| RawCheckInfo {
5046            schema: row.get::<_, String>(0),
5047            table: row.get::<_, String>(1),
5048            name: row.get::<_, String>(2),
5049            expression: row.get::<_, String>(3),
5050        })
5051        .collect();
5052
5053    Ok(())
5054}
5055
5056#[cfg(feature = "tokio-postgres")]
5057async fn query_pg_async_security(
5058    client: &tokio_postgres::Client,
5059    raw: &mut PostgresRawData,
5060) -> Result<(), CliError> {
5061    use drizzle_migrations::postgres::introspect::{RawPolicyInfo, RawRoleInfo, queries};
5062
5063    raw.roles = client
5064        .query(queries::ROLES_QUERY, &[])
5065        .await
5066        .map_err(|e| CliError::Other(format!("Failed to query roles: {e}")))?
5067        .into_iter()
5068        .map(|row| RawRoleInfo {
5069            name: row.get::<_, String>(0),
5070            create_db: row.get::<_, bool>(1),
5071            create_role: row.get::<_, bool>(2),
5072            inherit: row.get::<_, bool>(3),
5073        })
5074        .collect();
5075
5076    raw.policies = client
5077        .query(queries::POLICIES_QUERY, &[])
5078        .await
5079        .map_err(|e| CliError::Other(format!("Failed to query policies: {e}")))?
5080        .into_iter()
5081        .map(|row| RawPolicyInfo {
5082            schema: row.get::<_, String>(0),
5083            table: row.get::<_, String>(1),
5084            name: row.get::<_, String>(2),
5085            as_clause: row.get::<_, String>(3),
5086            for_clause: row.get::<_, String>(4),
5087            to: row.get::<_, Vec<String>>(5),
5088            using: row.get::<_, Option<String>>(6),
5089            with_check: row.get::<_, Option<String>>(7),
5090        })
5091        .collect();
5092
5093    Ok(())
5094}
5095
5096/// Minimal schema list for snapshot
5097#[cfg(any(feature = "postgres-sync", feature = "tokio-postgres"))]
5098#[derive(Debug, Clone)]
5099struct RawSchemaInfo {
5100    name: String,
5101}
5102
5103/// Aggregated raw introspection data collected from a `PostgreSQL` database.
5104///
5105/// Shared between `postgres-sync` and `tokio-postgres` code paths — the
5106/// collection phase differs (blocking vs async), but the downstream
5107/// processing is identical.
5108#[cfg(any(feature = "postgres-sync", feature = "tokio-postgres"))]
5109struct PostgresRawData {
5110    schemas: Vec<RawSchemaInfo>,
5111    tables: Vec<drizzle_migrations::postgres::introspect::RawTableInfo>,
5112    columns: Vec<drizzle_migrations::postgres::introspect::RawColumnInfo>,
5113    enums: Vec<drizzle_migrations::postgres::introspect::RawEnumInfo>,
5114    sequences: Vec<drizzle_migrations::postgres::introspect::RawSequenceInfo>,
5115    views: Vec<drizzle_migrations::postgres::introspect::RawViewInfo>,
5116    indexes: Vec<drizzle_migrations::postgres::introspect::RawIndexInfo>,
5117    foreign_keys: Vec<drizzle_migrations::postgres::introspect::RawForeignKeyInfo>,
5118    primary_keys: Vec<drizzle_migrations::postgres::introspect::RawPrimaryKeyInfo>,
5119    uniques: Vec<drizzle_migrations::postgres::introspect::RawUniqueInfo>,
5120    checks: Vec<drizzle_migrations::postgres::introspect::RawCheckInfo>,
5121    roles: Vec<drizzle_migrations::postgres::introspect::RawRoleInfo>,
5122    policies: Vec<drizzle_migrations::postgres::introspect::RawPolicyInfo>,
5123}
5124
5125/// Build a [`PostgresDDL`] from raw introspection data.
5126///
5127/// Identical across the sync and async paths — only the query phase differs.
5128#[cfg(any(feature = "postgres-sync", feature = "tokio-postgres"))]
5129fn build_postgres_ddl(
5130    raw: PostgresRawData,
5131) -> drizzle_migrations::postgres::collection::PostgresDDL {
5132    use drizzle_migrations::postgres::ddl::Schema;
5133    use drizzle_migrations::postgres::introspect::{RawIntrospection, assemble_ddl};
5134
5135    assemble_ddl(RawIntrospection {
5136        schemas: raw
5137            .schemas
5138            .into_iter()
5139            .map(|schema| Schema::new(schema.name))
5140            .collect(),
5141        tables: raw.tables,
5142        columns: raw.columns,
5143        enums: raw.enums,
5144        sequences: raw.sequences,
5145        views: raw.views,
5146        indexes: raw.indexes,
5147        foreign_keys: raw.foreign_keys,
5148        primary_keys: raw.primary_keys,
5149        unique_constraints: raw.uniques,
5150        check_constraints: raw.checks,
5151        roles: raw.roles,
5152        policies: raw.policies,
5153    })
5154}
5155
5156/// Package a generated DDL + generated code into an [`IntrospectResult`].
5157#[cfg(any(feature = "postgres-sync", feature = "tokio-postgres"))]
5158fn finalize_postgres_introspection(
5159    ddl: &drizzle_migrations::postgres::collection::PostgresDDL,
5160    url: &str,
5161) -> IntrospectResult {
5162    use drizzle_migrations::postgres::codegen::{
5163        CodegenOptions, FieldCasing, generate_rust_schema,
5164    };
5165
5166    let options = CodegenOptions {
5167        module_doc: Some(format!(
5168            "Schema introspected from {}",
5169            crate::output::mask_url(url)
5170        )),
5171        include_schema: true,
5172        schema_name: "Schema".to_string(),
5173        use_pub: true,
5174        field_casing: FieldCasing::default(),
5175    };
5176    let generated = generate_rust_schema(ddl, &options);
5177
5178    let mut snap = drizzle_migrations::postgres::PostgresSnapshot::new();
5179    for entity in ddl.to_entities() {
5180        snap.add_entity(entity);
5181    }
5182
5183    IntrospectResult {
5184        schema_code: generated.code,
5185        table_count: ddl.tables.list().len(),
5186        index_count: ddl.indexes.list().len(),
5187        view_count: ddl.views.list().len(),
5188        warnings: generated.warnings,
5189        mysql_catalog_defaults: None,
5190        snapshot: Snapshot::Postgres(snap),
5191        snapshot_path: std::path::PathBuf::new(),
5192    }
5193}
5194
5195#[cfg(test)]
5196mod tests {
5197    use super::*;
5198
5199    #[cfg(any(feature = "postgres-sync", feature = "tokio-postgres"))]
5200    fn test_postgres_url() -> String {
5201        std::env::var("DATABASE_URL")
5202            .unwrap_or_else(|_| "postgres://postgres:postgres@localhost:5432/drizzle_test".into())
5203    }
5204
5205    #[cfg(any(feature = "postgres-sync", feature = "tokio-postgres"))]
5206    fn test_postgres_creds() -> crate::config::PostgresCreds {
5207        crate::config::PostgresCreds::Url(test_postgres_url().into_boxed_str())
5208    }
5209
5210    #[cfg(any(feature = "postgres-sync", feature = "tokio-postgres"))]
5211    fn unique_pg_name(prefix: &str) -> String {
5212        use std::time::{SystemTime, UNIX_EPOCH};
5213
5214        let nanos = SystemTime::now()
5215            .duration_since(UNIX_EPOCH)
5216            .expect("time went backwards")
5217            .as_nanos();
5218        format!("{}_{}_{}", prefix, std::process::id(), nanos)
5219    }
5220
5221    #[test]
5222    fn destructive_statement_detection_covers_drop_variants() {
5223        assert!(is_destructive_statement("DROP TABLE users;"));
5224        assert!(is_destructive_statement("DROP VIEW active_users;"));
5225        assert!(is_destructive_statement("DROP TYPE status;"));
5226        assert!(is_destructive_statement("DROP SCHEMA auth;"));
5227        assert!(is_destructive_statement("DROP ROLE app_user;"));
5228        assert!(is_destructive_statement(
5229            "DROP POLICY users_rls_policy ON users;"
5230        ));
5231        assert!(is_destructive_statement("TRUNCATE users;"));
5232        assert!(is_destructive_statement(
5233            "ALTER TABLE users DROP CONSTRAINT users_email_key;"
5234        ));
5235
5236        assert!(!is_destructive_statement("CREATE TABLE users(id INTEGER);"));
5237        assert!(!is_destructive_statement(
5238            "ALTER TABLE users ADD COLUMN email text;"
5239        ));
5240    }
5241
5242    #[test]
5243    fn mysql_structural_warnings_require_push_confirmation() {
5244        let statements =
5245            vec!["ALTER TABLE `users` MODIFY COLUMN `email` varchar(32) NOT NULL;".to_string()];
5246        let warnings = vec![
5247            "changing the type of users.email can truncate or reject existing values".to_string(),
5248        ];
5249
5250        assert!(push_requires_confirmation(
5251            Dialect::Mysql,
5252            &statements,
5253            &warnings,
5254        ));
5255        assert!(!push_requires_confirmation(
5256            Dialect::Mysql,
5257            &statements,
5258            &[],
5259        ));
5260    }
5261
5262    #[cfg(feature = "rusqlite")]
5263    #[test]
5264    fn sqlite_migrations_run_both_when_created_at_collides() {
5265        // Regression for drizzle-orm beta.19: migration identity is the folder
5266        // name, not the `created_at` timestamp. Two migrations that share a
5267        // wall-second must both apply.
5268        use drizzle_migrations::{Migration, Migrations};
5269
5270        let dir = tempfile::tempdir().expect("tempdir");
5271        let db_path = dir.path().join("migrate.sqlite");
5272        let db_path_str = db_path.to_string_lossy().to_string();
5273
5274        let first_set = Migrations::new(
5275            vec![Migration::with_hash(
5276                "20230331141203_first",
5277                "hash_one",
5278                1_680_271_923_000,
5279                vec!["CREATE TABLE created_at_dedupe_a (id INTEGER PRIMARY KEY)".to_string()],
5280            )],
5281            drizzle_types::Dialect::SQLite,
5282        );
5283
5284        let first =
5285            run_sqlite_migrations(&first_set, &db_path_str, false).expect("first migrate succeeds");
5286        assert_eq!(first.applied_count, 1);
5287
5288        let second_set = Migrations::new(
5289            vec![
5290                Migration::with_hash(
5291                    "20230331141203_first",
5292                    "hash_one",
5293                    1_680_271_923_000,
5294                    vec!["CREATE TABLE created_at_dedupe_a (id INTEGER PRIMARY KEY)".to_string()],
5295                ),
5296                Migration::with_hash(
5297                    "20230331141203_second",
5298                    "hash_two",
5299                    1_680_271_923_000,
5300                    vec!["CREATE TABLE created_at_dedupe_b (id INTEGER PRIMARY KEY)".to_string()],
5301                ),
5302            ],
5303            drizzle_types::Dialect::SQLite,
5304        );
5305
5306        let second = run_sqlite_migrations(&second_set, &db_path_str, false)
5307            .expect("second migrate succeeds");
5308        assert_eq!(
5309            second.applied_count, 1,
5310            "only the second (newly introduced by name) migration should apply"
5311        );
5312
5313        let conn = rusqlite::Connection::open(&db_path).expect("open sqlite");
5314        let rows: i64 = conn
5315            .query_row("SELECT COUNT(*) FROM __drizzle_migrations", [], |row| {
5316                row.get(0)
5317            })
5318            .expect("count migrations rows");
5319        assert_eq!(rows, 2, "both migration records should be stored");
5320
5321        let table_b_exists: i64 = conn
5322            .query_row(
5323                "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='created_at_dedupe_b'",
5324                [],
5325                |row| row.get(0),
5326            )
5327            .expect("query sqlite_master");
5328        assert_eq!(
5329            table_b_exists, 1,
5330            "the new-name migration must execute even though created_at collides"
5331        );
5332    }
5333
5334    #[cfg(feature = "rusqlite")]
5335    #[test]
5336    fn sqlite_cli_rebuild_preserves_cascade_children() {
5337        use drizzle_migrations::{Migration, Migrations};
5338
5339        let dir = tempfile::tempdir().expect("tempdir");
5340        let db_path = dir.path().join("rebuild.sqlite");
5341        let conn = rusqlite::Connection::open(&db_path).expect("open sqlite");
5342        conn.execute_batch(
5343            "PRAGMA foreign_keys=ON;
5344             CREATE TABLE rebuild_parent (id INTEGER PRIMARY KEY);
5345             CREATE TABLE rebuild_child (
5346                 id INTEGER PRIMARY KEY,
5347                 parent_id INTEGER NOT NULL REFERENCES rebuild_parent(id) ON DELETE CASCADE
5348             );
5349             INSERT INTO rebuild_parent VALUES (1);
5350             INSERT INTO rebuild_child VALUES (1, 1);",
5351        )
5352        .expect("seed related tables");
5353
5354        let migration = Migration::new(
5355            "20260825000000_rebuild",
5356            "PRAGMA foreign_keys=OFF;
5357             --> statement-breakpoint
5358             CREATE TABLE rebuild_parent_new (id INTEGER PRIMARY KEY) STRICT;
5359             --> statement-breakpoint
5360             INSERT INTO rebuild_parent_new SELECT * FROM rebuild_parent;
5361             --> statement-breakpoint
5362             DROP TABLE rebuild_parent;
5363             --> statement-breakpoint
5364             ALTER TABLE rebuild_parent_new RENAME TO rebuild_parent;
5365             --> statement-breakpoint
5366             PRAGMA foreign_keys=ON;",
5367        );
5368        let set = Migrations::new(vec![migration], drizzle_types::Dialect::SQLite);
5369
5370        run_sqlite_migrations_on_connection(&set, &conn, false).expect("run safe rebuild");
5371
5372        let children: i64 = conn
5373            .query_row("SELECT count(*) FROM rebuild_child", [], |row| row.get(0))
5374            .expect("count cascade children");
5375        assert_eq!(children, 1, "rebuild must not trigger ON DELETE CASCADE");
5376        let violations: i64 = conn
5377            .query_row("SELECT count(*) FROM pragma_foreign_key_check", [], |row| {
5378                row.get(0)
5379            })
5380            .expect("check rebuilt foreign keys");
5381        assert_eq!(violations, 0);
5382        let foreign_keys: i64 = conn
5383            .query_row("PRAGMA foreign_keys", [], |row| row.get(0))
5384            .expect("read restored foreign-key mode");
5385        assert_eq!(foreign_keys, 1);
5386    }
5387
5388    #[cfg(feature = "rusqlite")]
5389    #[test]
5390    fn sqlite_push_rebuild_preserves_cascade_children() {
5391        let conn = rusqlite::Connection::open_in_memory().expect("open sqlite");
5392        conn.execute_batch(
5393            "PRAGMA foreign_keys=ON;
5394             CREATE TABLE rebuild_parent (id INTEGER PRIMARY KEY);
5395             CREATE TABLE rebuild_child (
5396                 id INTEGER PRIMARY KEY,
5397                 parent_id INTEGER NOT NULL REFERENCES rebuild_parent(id) ON DELETE CASCADE
5398             );
5399             INSERT INTO rebuild_parent VALUES (1);
5400             INSERT INTO rebuild_child VALUES (1, 1);",
5401        )
5402        .expect("seed related tables");
5403        let migration = drizzle_migrations::Migration::new(
5404            "push_rebuild",
5405            "PRAGMA foreign_keys=OFF;
5406             --> statement-breakpoint
5407             CREATE TABLE rebuild_parent_new (id INTEGER PRIMARY KEY) STRICT;
5408             --> statement-breakpoint
5409             INSERT INTO rebuild_parent_new SELECT * FROM rebuild_parent;
5410             --> statement-breakpoint
5411             DROP TABLE rebuild_parent;
5412             --> statement-breakpoint
5413             ALTER TABLE rebuild_parent_new RENAME TO rebuild_parent;
5414             --> statement-breakpoint
5415             PRAGMA foreign_keys=ON;",
5416        );
5417
5418        execute_sqlite_statements_on_connection(&conn, migration.statements())
5419            .expect("apply push statements");
5420
5421        let children: i64 = conn
5422            .query_row("SELECT count(*) FROM rebuild_child", [], |row| row.get(0))
5423            .expect("count cascade children");
5424        assert_eq!(children, 1, "push rebuild must not trigger cascade");
5425        let foreign_keys: i64 = conn
5426            .query_row("PRAGMA foreign_keys", [], |row| row.get(0))
5427            .expect("read restored foreign-key mode");
5428        assert_eq!(foreign_keys, 1);
5429    }
5430
5431    #[cfg(feature = "rusqlite")]
5432    #[test]
5433    fn declined_destructive_push_is_an_abort_and_applies_nothing() {
5434        let dir = tempfile::tempdir().expect("tempdir");
5435        let db_path = dir.path().join("push.sqlite");
5436        rusqlite::Connection::open(&db_path)
5437            .expect("open sqlite")
5438            .execute_batch("CREATE TABLE keep_me (id INTEGER PRIMARY KEY);")
5439            .expect("seed table");
5440
5441        let connection = ResolvedConnection {
5442            dialect: Dialect::Sqlite,
5443            driver: Driver::Rusqlite,
5444            credentials: Credentials::Sqlite {
5445                path: db_path.to_string_lossy().into_owned().into_boxed_str(),
5446            },
5447        };
5448        let plan = PushPlan {
5449            sql_statements: vec!["DROP TABLE `keep_me`;".to_string()],
5450            warnings: Vec::new(),
5451            destructive: true,
5452        };
5453
5454        let error = apply_push_with_confirmation(&connection, &plan, false, || Ok(false))
5455            .expect_err("declining the prompt must not report success");
5456        assert!(matches!(error, CliError::Aborted(_)), "{error:?}");
5457        assert!(error.to_string().contains("aborted"), "{error}");
5458
5459        let kept: i64 = rusqlite::Connection::open(&db_path)
5460            .expect("reopen sqlite")
5461            .query_row(
5462                "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='keep_me'",
5463                [],
5464                |row| row.get(0),
5465            )
5466            .expect("query sqlite_master");
5467        assert_eq!(kept, 1, "a declined push must not execute any statement");
5468    }
5469
5470    #[cfg(feature = "rusqlite")]
5471    #[test]
5472    fn run_migrations_creates_metadata_table_with_no_local_migrations() {
5473        let dir = tempfile::tempdir().expect("tempdir");
5474        let db_path = dir.path().join("empty.sqlite");
5475        let migrations_dir = dir.path().join("migrations");
5476        std::fs::create_dir_all(&migrations_dir).expect("create migrations dir");
5477
5478        let creds = crate::config::Credentials::Sqlite {
5479            path: db_path.to_string_lossy().to_string().into_boxed_str(),
5480        };
5481        let connection = ResolvedConnection {
5482            dialect: crate::config::Dialect::Sqlite,
5483            driver: Driver::Rusqlite,
5484            credentials: creds,
5485        };
5486
5487        let result = run_migrations(
5488            &connection,
5489            &migrations_dir,
5490            "__drizzle_migrations",
5491            "drizzle",
5492            false,
5493        )
5494        .expect("run migrations");
5495        assert_eq!(result.applied_count, 0);
5496
5497        let conn = rusqlite::Connection::open(&db_path).expect("open sqlite");
5498        let exists: i64 = conn
5499            .query_row(
5500                "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='__drizzle_migrations'",
5501                [],
5502                |row| row.get(0),
5503            )
5504            .expect("check metadata table");
5505        assert_eq!(exists, 1, "migrations metadata table should be created");
5506    }
5507
5508    #[cfg(any(
5509        feature = "rusqlite",
5510        feature = "libsql",
5511        feature = "turso",
5512        feature = "postgres-sync",
5513        feature = "tokio-postgres",
5514        feature = "mysql-sync",
5515        feature = "mysql-async"
5516    ))]
5517    #[test]
5518    fn validate_init_metadata_matches_drizzle_orm_semantics() {
5519        use drizzle_migrations::{Migration, Migrations};
5520
5521        let empty_set = Migrations::empty(drizzle_types::Dialect::SQLite);
5522        validate_init_metadata(&[], &empty_set).expect("empty local migrations should be allowed");
5523
5524        let single = Migrations::new(
5525            vec![Migration::with_hash(
5526                "20230331141203_init",
5527                "hash_single",
5528                1_680_271_923_000,
5529                vec!["CREATE TABLE t(id INTEGER PRIMARY KEY)".to_string()],
5530            )],
5531            drizzle_types::Dialect::SQLite,
5532        );
5533        validate_init_metadata(&[], &single).expect("single local migration should be allowed");
5534
5535        let multiple = Migrations::new(
5536            vec![
5537                Migration::with_hash(
5538                    "20230331141203_first",
5539                    "hash_a",
5540                    1_680_271_923_000,
5541                    vec!["CREATE TABLE a(id INTEGER PRIMARY KEY)".to_string()],
5542                ),
5543                Migration::with_hash(
5544                    "20230331150000_second",
5545                    "hash_b",
5546                    1_680_275_400_000,
5547                    vec!["CREATE TABLE b(id INTEGER PRIMARY KEY)".to_string()],
5548                ),
5549            ],
5550            drizzle_types::Dialect::SQLite,
5551        );
5552
5553        let err =
5554            validate_init_metadata(&[], &multiple).expect_err("multiple local migrations rejected");
5555        assert_eq!(
5556            err.to_string(),
5557            "--init can't be used with existing migrations"
5558        );
5559
5560        let err = validate_init_metadata(&["20230331141203_init".to_string()], &single)
5561            .expect_err("existing db metadata should be rejected");
5562        assert_eq!(
5563            err.to_string(),
5564            "--init can't be used when database already has migrations set"
5565        );
5566    }
5567
5568    #[cfg(any(
5569        feature = "rusqlite",
5570        feature = "libsql",
5571        feature = "turso",
5572        feature = "postgres-sync",
5573        feature = "tokio-postgres",
5574        feature = "mysql-sync",
5575        feature = "mysql-async",
5576        feature = "d1-http",
5577    ))]
5578    #[test]
5579    fn integrity_findings_detect_hash_drift() {
5580        use drizzle_migrations::{Migration, Migrations};
5581
5582        let set = Migrations::new(
5583            vec![Migration::with_hash(
5584                "20230331141203_verify",
5585                "local_hash",
5586                1_680_271_923_000,
5587                vec!["CREATE TABLE t(id INTEGER PRIMARY KEY)".to_string()],
5588            )],
5589            drizzle_types::Dialect::SQLite,
5590        );
5591
5592        let applied = vec![AppliedMigrationRecord {
5593            hash: "db_hash".to_string(),
5594            name: "20230331141203_verify".to_string(),
5595            dirty: false,
5596        }];
5597
5598        let findings =
5599            collect_integrity_findings(&set, &applied).expect("local set is well-formed");
5600        assert_eq!(findings.len(), 1, "expected one finding: {findings:?}");
5601        assert!(
5602            findings[0].contains("20230331141203_verify") && findings[0].contains("drifted"),
5603            "unexpected finding: {}",
5604            findings[0]
5605        );
5606    }
5607
5608    #[cfg(any(
5609        feature = "rusqlite",
5610        feature = "libsql",
5611        feature = "turso",
5612        feature = "postgres-sync",
5613        feature = "tokio-postgres",
5614        feature = "mysql-sync",
5615        feature = "mysql-async",
5616        feature = "d1-http",
5617    ))]
5618    #[test]
5619    fn integrity_findings_report_dirty_and_missing_local_rows() {
5620        use drizzle_migrations::{Migration, Migrations};
5621
5622        let set = Migrations::new(
5623            vec![Migration::with_hash(
5624                "20230331141203_first",
5625                "hash_a",
5626                1_680_271_923_000,
5627                vec!["CREATE TABLE a(id INTEGER PRIMARY KEY)".to_string()],
5628            )],
5629            drizzle_types::Dialect::SQLite,
5630        );
5631
5632        let applied = vec![
5633            AppliedMigrationRecord {
5634                hash: "hash_a".to_string(),
5635                name: "20230331141203_first".to_string(),
5636                dirty: true,
5637            },
5638            AppliedMigrationRecord {
5639                hash: "hash_gone".to_string(),
5640                name: "20230401000000_deleted_locally".to_string(),
5641                dirty: false,
5642            },
5643        ];
5644
5645        let findings =
5646            collect_integrity_findings(&set, &applied).expect("local set is well-formed");
5647        assert_eq!(findings.len(), 2, "expected two findings: {findings:?}");
5648        assert!(
5649            findings[0].contains("interrupted mid-apply"),
5650            "unexpected finding: {}",
5651            findings[0]
5652        );
5653        assert!(
5654            findings[1].contains("no local migration"),
5655            "unexpected finding: {}",
5656            findings[1]
5657        );
5658    }
5659
5660    #[cfg(any(
5661        feature = "rusqlite",
5662        feature = "libsql",
5663        feature = "turso",
5664        feature = "postgres-sync",
5665        feature = "tokio-postgres",
5666        feature = "mysql-sync",
5667        feature = "mysql-async",
5668        feature = "d1-http",
5669    ))]
5670    #[test]
5671    fn mysql_integrity_findings_do_not_recommend_automatic_repair() {
5672        use drizzle_migrations::{Migration, Migrations};
5673
5674        let set = Migrations::new(
5675            vec![Migration::with_hash(
5676                "20230331141203_first",
5677                "hash_a",
5678                1_680_271_923_000,
5679                vec!["CREATE TABLE a(id INTEGER PRIMARY KEY)".to_string()],
5680            )],
5681            drizzle_types::Dialect::MySQL,
5682        );
5683        let findings = collect_integrity_findings(
5684            &set,
5685            &[AppliedMigrationRecord {
5686                hash: "hash_a".to_string(),
5687                name: "20230331141203_first".to_string(),
5688                dirty: true,
5689            }],
5690        )
5691        .expect("local set is well-formed");
5692
5693        assert!(findings[0].contains("resolve it manually"), "{findings:?}");
5694        assert!(!findings[0].contains("--repair"), "{findings:?}");
5695    }
5696
5697    #[cfg(any(
5698        feature = "rusqlite",
5699        feature = "libsql",
5700        feature = "turso",
5701        feature = "postgres-sync",
5702        feature = "tokio-postgres",
5703        feature = "mysql-sync",
5704        feature = "mysql-async",
5705        feature = "d1-http",
5706    ))]
5707    #[test]
5708    fn dirty_rows_are_not_counted_as_applied_in_plan() {
5709        use drizzle_migrations::{Migration, Migrations};
5710
5711        let set = Migrations::new(
5712            vec![Migration::with_hash(
5713                "20230331141203_first",
5714                "hash_a",
5715                1_680_271_923_000,
5716                vec!["CREATE TABLE a(id INTEGER PRIMARY KEY)".to_string()],
5717            )],
5718            drizzle_types::Dialect::SQLite,
5719        );
5720
5721        let applied = vec![AppliedMigrationRecord {
5722            hash: "hash_a".to_string(),
5723            name: "20230331141203_first".to_string(),
5724            dirty: true,
5725        }];
5726
5727        let plan = build_migration_plan(&set, &applied).expect("build migration plan");
5728        assert_eq!(plan.applied_count, 0);
5729        assert_eq!(plan.pending_count, 1, "interrupted migration is unfinished");
5730        assert_eq!(plan.findings.len(), 1);
5731    }
5732
5733    /// `migrate --plan`, `--dry-run` and `--verify` must not write: no
5734    /// database file, no tracking table.
5735    #[cfg(feature = "rusqlite")]
5736    #[test]
5737    fn sqlite_plan_creates_neither_the_database_nor_the_tracking_table() {
5738        use drizzle_migrations::{Migration, Migrations};
5739
5740        let set = Migrations::new(
5741            vec![Migration::with_hash(
5742                "20230331141203_first",
5743                "hash_a",
5744                1_680_271_923_000,
5745                vec!["CREATE TABLE a(id INTEGER PRIMARY KEY)".to_string()],
5746            )],
5747            drizzle_types::Dialect::SQLite,
5748        );
5749        let dir = tempfile::tempdir().expect("tempdir");
5750
5751        let missing = dir.path().join("missing.db");
5752        let plan = inspect_sqlite_migrations(&set, missing.to_str().expect("utf-8 path"))
5753            .expect("plan a missing database");
5754        assert_eq!(plan.pending_count, 1);
5755        assert!(!missing.exists(), "planning created the database file");
5756
5757        let existing = dir.path().join("existing.db");
5758        rusqlite::Connection::open(&existing)
5759            .and_then(|conn| conn.execute("CREATE TABLE users(id INTEGER PRIMARY KEY)", []))
5760            .expect("seed database");
5761        let plan = inspect_sqlite_migrations(&set, existing.to_str().expect("utf-8 path"))
5762            .expect("plan an existing database");
5763        assert_eq!(plan.pending_count, 1);
5764        let tracking_tables: i64 = rusqlite::Connection::open(&existing)
5765            .and_then(|conn| {
5766                conn.query_row(
5767                    "SELECT count(*) FROM sqlite_master WHERE name = ?1",
5768                    [set.table_name()],
5769                    |row| row.get(0),
5770                )
5771            })
5772            .expect("inspect database");
5773        assert_eq!(tracking_tables, 0, "planning created the tracking table");
5774    }
5775
5776    #[cfg(any(
5777        feature = "rusqlite",
5778        feature = "libsql",
5779        feature = "turso",
5780        feature = "postgres-sync",
5781        feature = "tokio-postgres",
5782        feature = "mysql-sync",
5783        feature = "mysql-async",
5784        feature = "d1-http",
5785    ))]
5786    #[test]
5787    fn build_migration_plan_counts_pending_statements() {
5788        use drizzle_migrations::{Migration, Migrations};
5789
5790        let set = Migrations::new(
5791            vec![
5792                Migration::with_hash(
5793                    "20230331141203_first",
5794                    "hash_a",
5795                    1_680_271_923_000,
5796                    vec![
5797                        "CREATE TABLE a(id INTEGER PRIMARY KEY)".to_string(),
5798                        "CREATE INDEX a_id_idx ON a(id)".to_string(),
5799                    ],
5800                ),
5801                Migration::with_hash(
5802                    "20230331150000_second",
5803                    "hash_b",
5804                    1_680_275_400_000,
5805                    vec!["CREATE TABLE b(id INTEGER PRIMARY KEY)".to_string()],
5806                ),
5807            ],
5808            drizzle_types::Dialect::SQLite,
5809        );
5810
5811        let applied = vec![AppliedMigrationRecord {
5812            hash: "hash_a".to_string(),
5813            name: "20230331141203_first".to_string(),
5814            dirty: false,
5815        }];
5816
5817        let plan = build_migration_plan(&set, &applied).expect("build migration plan");
5818        assert_eq!(plan.applied_count, 1);
5819        assert_eq!(plan.pending_count, 1);
5820        assert_eq!(plan.pending_statements, 1);
5821        assert_eq!(plan.pending_migrations, vec!["20230331150000_second"]);
5822        assert!(plan.findings.is_empty());
5823    }
5824
5825    #[cfg(any(feature = "postgres-sync", feature = "tokio-postgres"))]
5826    #[test]
5827    fn postgres_concurrent_index_detection_is_case_insensitive() {
5828        assert!(is_postgres_concurrent_index_statement(
5829            "CREATE INDEX CONCURRENTLY users_email_idx ON users (email);"
5830        ));
5831        assert!(is_postgres_concurrent_index_statement(
5832            "CREATE UNIQUE INDEX CONCURRENTLY users_email_idx ON users (email);"
5833        ));
5834        assert!(is_postgres_concurrent_index_statement(
5835            "drop index concurrently users_email_idx;"
5836        ));
5837        assert!(!is_postgres_concurrent_index_statement(
5838            "CREATE INDEX users_email_idx ON users (email);"
5839        ));
5840    }
5841
5842    #[cfg(any(feature = "postgres-sync", feature = "tokio-postgres"))]
5843    #[test]
5844    fn postgres_concurrent_index_detection_over_statement_list() {
5845        let with_concurrent = vec![
5846            "CREATE TABLE users(id integer);".to_string(),
5847            "CREATE INDEX CONCURRENTLY users_email_idx ON users (id);".to_string(),
5848        ];
5849        let without_concurrent = vec![
5850            "CREATE TABLE users(id integer);".to_string(),
5851            "CREATE INDEX users_email_idx ON users (id);".to_string(),
5852        ];
5853
5854        assert!(has_postgres_concurrent_index(&with_concurrent));
5855        assert!(!has_postgres_concurrent_index(&without_concurrent));
5856    }
5857
5858    #[test]
5859    fn generate_push_sql_includes_concurrent_postgres_index() {
5860        use drizzle_migrations::parser::SchemaParser;
5861        use drizzle_types::Dialect;
5862
5863        let previous = r#"
5864#[PostgresTable]
5865pub struct Users {
5866    #[column(primary)]
5867    pub id: i32,
5868    pub email: String,
5869}
5870"#;
5871
5872        let current = r#"
5873#[PostgresTable]
5874pub struct Users {
5875    #[column(primary)]
5876    pub id: i32,
5877    pub email: String,
5878}
5879
5880#[PostgresIndex(concurrent)]
5881pub struct UsersEmailIdx(Users::email);
5882"#;
5883
5884        let prev =
5885            Snapshot::from_parse_result(&SchemaParser::parse(previous), Dialect::PostgreSQL, None);
5886        let curr =
5887            Snapshot::from_parse_result(&SchemaParser::parse(current), Dialect::PostgreSQL, None);
5888
5889        let (sql, warnings) =
5890            generate_push_sql(&prev, &curr, false, &drizzle_migrations::DiffOptions::new())
5891                .expect("push sql generation");
5892        assert!(warnings.is_empty());
5893        assert_eq!(sql.len(), 1);
5894        assert_eq!(
5895            sql[0],
5896            "CREATE INDEX CONCURRENTLY \"users_email_idx\" ON \"users\"(\"email\");"
5897        );
5898    }
5899
5900    #[test]
5901    fn generate_push_sql_uses_live_mysql_catalog_defaults() {
5902        use drizzle_migrations::mysql::MySQLCatalogDefaults;
5903        use drizzle_migrations::parser::SchemaParser;
5904        use drizzle_types::Dialect;
5905
5906        let introspected = r#"
5907#[MySQLTable(NAME = "users")]
5908pub struct Users {
5909    #[column(PRIMARY)]
5910    pub id: i32,
5911}
5912"#;
5913        let desired = r#"
5914#[MySQLTable(NAME = "users", ENGINE = "InnoDB")]
5915pub struct Users {
5916    #[column(PRIMARY)]
5917    pub id: i32,
5918}
5919"#;
5920        let current =
5921            Snapshot::from_parse_result(&SchemaParser::parse(introspected), Dialect::MySQL, None);
5922        let desired =
5923            Snapshot::from_parse_result(&SchemaParser::parse(desired), Dialect::MySQL, None);
5924        let options = drizzle_migrations::DiffOptions::new()
5925            .mysql_catalog_defaults(MySQLCatalogDefaults::new().engine("InnoDB"));
5926
5927        let (sql, warnings) = generate_push_sql(&current, &desired, false, &options)
5928            .expect("push SQL with live MySQL defaults");
5929        assert!(sql.is_empty(), "inherited defaults must not churn: {sql:?}");
5930        assert!(warnings.is_empty());
5931    }
5932
5933    #[test]
5934    fn filter_patterns_support_negation_globs() {
5935        let raw = vec![
5936            "users_*".to_string(),
5937            "!users_4".to_string(),
5938            "!ad*".to_string(),
5939        ];
5940        let patterns = compile_patterns(Some(&raw)).expect("compile patterns");
5941
5942        assert!(matches_patterns("users_1", patterns.as_deref()));
5943        assert!(!matches_patterns("users_4", patterns.as_deref()));
5944        assert!(!matches_patterns("admin", patterns.as_deref()));
5945        assert!(!matches_patterns("audit", patterns.as_deref()));
5946    }
5947
5948    #[test]
5949    fn postgres_table_filter_matches_table_name_only() {
5950        use drizzle_migrations::parser::SchemaParser;
5951        use drizzle_migrations::schema::Snapshot;
5952        use drizzle_types::Dialect as BaseDialect;
5953
5954        let code = r#"
5955#[PostgresTable(schema = "admin")]
5956pub struct AuditLog {
5957    #[column(primary)]
5958    pub id: i32,
5959}
5960
5961#[PostgresTable(schema = "public")]
5962pub struct Users {
5963    #[column(primary)]
5964    pub id: i32,
5965}
5966"#;
5967
5968        let parsed = SchemaParser::parse(code);
5969        let mut snapshot = Snapshot::from_parse_result(&parsed, BaseDialect::PostgreSQL, None);
5970        let filters = SnapshotFilters {
5971            tables: Some(vec!["admin.*".to_string()]),
5972            schemas: None,
5973            extensions: None,
5974            roles: None,
5975        };
5976
5977        apply_snapshot_filters(&mut snapshot, crate::config::Dialect::Postgresql, &filters)
5978            .expect("apply filters");
5979
5980        let remaining_tables = match snapshot {
5981            Snapshot::Postgres(s) => s
5982                .ddl
5983                .iter()
5984                .filter_map(|e| {
5985                    if let drizzle_types::postgres::ddl::PostgresEntity::Table(t) = e {
5986                        Some((t.schema.to_string(), t.name.to_string()))
5987                    } else {
5988                        None
5989                    }
5990                })
5991                .collect::<Vec<_>>(),
5992            _ => panic!("expected postgres snapshot"),
5993        };
5994
5995        assert!(remaining_tables.is_empty());
5996    }
5997
5998    #[test]
5999    fn postgres_schema_and_table_filters_intersect() {
6000        use drizzle_migrations::parser::SchemaParser;
6001        use drizzle_migrations::schema::Snapshot;
6002        use drizzle_types::Dialect as BaseDialect;
6003
6004        let code = r#"
6005#[PostgresTable(schema = "dev")]
6006pub struct UsersDev {
6007    #[column(primary)]
6008    pub id: i32,
6009}
6010
6011#[PostgresTable(schema = "public")]
6012pub struct UsersPublic {
6013    #[column(primary)]
6014    pub id: i32,
6015}
6016"#;
6017
6018        let parsed = SchemaParser::parse(code);
6019        let mut snapshot = Snapshot::from_parse_result(&parsed, BaseDialect::PostgreSQL, None);
6020        let filters = SnapshotFilters {
6021            tables: Some(vec!["users_*".to_string()]),
6022            schemas: Some(vec!["!dev".to_string()]),
6023            extensions: None,
6024            roles: None,
6025        };
6026
6027        apply_snapshot_filters(&mut snapshot, crate::config::Dialect::Postgresql, &filters)
6028            .expect("apply filters");
6029
6030        let remaining_tables = match snapshot {
6031            Snapshot::Postgres(s) => s
6032                .ddl
6033                .iter()
6034                .filter_map(|e| {
6035                    if let drizzle_types::postgres::ddl::PostgresEntity::Table(t) = e {
6036                        Some((t.schema.to_string(), t.name.to_string()))
6037                    } else {
6038                        None
6039                    }
6040                })
6041                .collect::<Vec<_>>(),
6042            _ => panic!("expected postgres snapshot"),
6043        };
6044
6045        assert_eq!(
6046            remaining_tables,
6047            vec![("public".to_string(), "users_public".to_string())]
6048        );
6049    }
6050
6051    #[test]
6052    fn postgres_extensions_filter_excludes_postgis_internal_objects() {
6053        use drizzle_migrations::parser::SchemaParser;
6054        use drizzle_migrations::schema::Snapshot;
6055        use drizzle_types::Dialect as BaseDialect;
6056
6057        let code = r#"
6058#[PostgresTable(schema = "topology")]
6059pub struct TopologyLayer {
6060    #[column(primary)]
6061    pub id: i32,
6062}
6063
6064#[PostgresTable]
6065pub struct SpatialRefSys {
6066    #[column(primary)]
6067    pub id: i32,
6068}
6069
6070#[PostgresTable]
6071pub struct Users {
6072    #[column(primary)]
6073    pub id: i32,
6074}
6075"#;
6076
6077        let parsed = SchemaParser::parse(code);
6078        let mut snapshot = Snapshot::from_parse_result(&parsed, BaseDialect::PostgreSQL, None);
6079        let filters = SnapshotFilters {
6080            tables: None,
6081            schemas: None,
6082            extensions: Some(vec![Extension::Postgis]),
6083            roles: None,
6084        };
6085
6086        apply_snapshot_filters(&mut snapshot, crate::config::Dialect::Postgresql, &filters)
6087            .expect("apply filters");
6088
6089        let remaining_tables = match snapshot {
6090            Snapshot::Postgres(s) => s
6091                .ddl
6092                .iter()
6093                .filter_map(|e| {
6094                    if let drizzle_types::postgres::ddl::PostgresEntity::Table(t) = e {
6095                        Some((t.schema.to_string(), t.name.to_string()))
6096                    } else {
6097                        None
6098                    }
6099                })
6100                .collect::<Vec<_>>(),
6101            _ => panic!("expected postgres snapshot"),
6102        };
6103
6104        assert_eq!(
6105            remaining_tables,
6106            vec![("public".to_string(), "users".to_string())]
6107        );
6108    }
6109
6110    #[test]
6111    fn format_migration_sql_respects_breakpoints_flag() {
6112        let sql = vec![
6113            "CREATE TABLE users(id integer);".to_string(),
6114            "CREATE INDEX users_id_idx ON users(id);".to_string(),
6115        ];
6116
6117        let with_breakpoints = format_migration_sql(&sql, true);
6118        assert_eq!(
6119            with_breakpoints,
6120            "CREATE TABLE users(id integer);\n--> statement-breakpoint\nCREATE INDEX users_id_idx ON users(id);"
6121        );
6122
6123        let without_breakpoints = format_migration_sql(&sql, false);
6124        assert_eq!(
6125            without_breakpoints,
6126            "CREATE TABLE users(id integer);\n\nCREATE INDEX users_id_idx ON users(id);"
6127        );
6128
6129        let empty = format_migration_sql(&[], false);
6130        assert_eq!(empty, "-- No tables to create (empty database)\n");
6131    }
6132
6133    #[test]
6134    fn regenerate_sqlite_schema_applies_introspect_casing() {
6135        use crate::config::{Casing, IntrospectCasing};
6136        use drizzle_migrations::parser::SchemaParser;
6137        use drizzle_types::Dialect as BaseDialect;
6138
6139        let code = r#"
6140#[SQLiteTable]
6141pub struct AuditLogs {
6142    #[column(primary)]
6143    pub id: i64,
6144    pub user_name: String,
6145}
6146"#;
6147
6148        let parsed = SchemaParser::parse(code);
6149        let snapshot =
6150            Snapshot::from_parse_result(&parsed, BaseDialect::SQLite, Some(Casing::SnakeCase));
6151
6152        let mut camel = IntrospectResult {
6153            schema_code: String::new(),
6154            table_count: 0,
6155            index_count: 0,
6156            view_count: 0,
6157            warnings: Vec::new(),
6158            mysql_catalog_defaults: None,
6159            snapshot: snapshot.clone(),
6160            snapshot_path: std::path::PathBuf::new(),
6161        };
6162        regenerate_schema_from_snapshot(
6163            &mut camel,
6164            crate::config::Dialect::Sqlite,
6165            Some(IntrospectCasing::Camel),
6166        );
6167
6168        assert_eq!(
6169            camel.schema_code,
6170            "\
6171//! Auto-generated SQLite schema from introspection
6172//!
6173//! Schema introspected from filtered database objects
6174
6175use drizzle::sqlite::prelude::*;
6176
6177#[SQLiteTable(name = \"audit_logs\")]
6178pub struct AuditLogs {
6179    #[column(primary)]
6180    pub id: i64,
6181    pub userName: String,
6182}
6183
6184#[derive(SQLiteSchema)]
6185pub struct Schema {
6186    pub auditLogs: AuditLogs,
6187}
6188"
6189        );
6190
6191        let mut preserve = IntrospectResult {
6192            schema_code: String::new(),
6193            table_count: 0,
6194            index_count: 0,
6195            view_count: 0,
6196            warnings: Vec::new(),
6197            mysql_catalog_defaults: None,
6198            snapshot,
6199            snapshot_path: std::path::PathBuf::new(),
6200        };
6201        regenerate_schema_from_snapshot(
6202            &mut preserve,
6203            crate::config::Dialect::Sqlite,
6204            Some(IntrospectCasing::Preserve),
6205        );
6206
6207        assert_eq!(
6208            preserve.schema_code,
6209            "\
6210//! Auto-generated SQLite schema from introspection
6211//!
6212//! Schema introspected from filtered database objects
6213
6214use drizzle::sqlite::prelude::*;
6215
6216#[SQLiteTable(name = \"audit_logs\")]
6217pub struct AuditLogs {
6218    #[column(primary)]
6219    pub id: i64,
6220    pub user_name: String,
6221}
6222
6223#[derive(SQLiteSchema)]
6224pub struct Schema {
6225    pub audit_logs: AuditLogs,
6226}
6227"
6228        );
6229    }
6230
6231    #[test]
6232    fn regenerate_postgres_schema_applies_introspect_casing() {
6233        use crate::config::{Casing, IntrospectCasing};
6234        use drizzle_migrations::parser::SchemaParser;
6235        use drizzle_types::Dialect as BaseDialect;
6236
6237        let code = r#"
6238#[PostgresTable]
6239pub struct AuditLogs {
6240    #[column(primary)]
6241    pub id: i32,
6242    pub user_name: String,
6243}
6244"#;
6245
6246        let parsed = SchemaParser::parse(code);
6247        let snapshot =
6248            Snapshot::from_parse_result(&parsed, BaseDialect::PostgreSQL, Some(Casing::SnakeCase));
6249
6250        let mut camel = IntrospectResult {
6251            schema_code: String::new(),
6252            table_count: 0,
6253            index_count: 0,
6254            view_count: 0,
6255            warnings: Vec::new(),
6256            mysql_catalog_defaults: None,
6257            snapshot: snapshot.clone(),
6258            snapshot_path: std::path::PathBuf::new(),
6259        };
6260        regenerate_schema_from_snapshot(
6261            &mut camel,
6262            crate::config::Dialect::Postgresql,
6263            Some(IntrospectCasing::Camel),
6264        );
6265
6266        assert_eq!(
6267            camel.schema_code,
6268            "\
6269//! Auto-generated PostgreSQL schema from introspection
6270//!
6271//! Schema introspected from filtered database objects
6272
6273use drizzle::postgres::prelude::*;
6274
6275#[PostgresTable]
6276pub struct AuditLogs {
6277    #[column(primary)]
6278    pub id: i32,
6279    pub userName: String,
6280}
6281
6282#[derive(PostgresSchema)]
6283pub struct Schema {
6284    pub auditLogs: AuditLogs,
6285}
6286"
6287        );
6288
6289        let mut preserve = IntrospectResult {
6290            schema_code: String::new(),
6291            table_count: 0,
6292            index_count: 0,
6293            view_count: 0,
6294            warnings: Vec::new(),
6295            mysql_catalog_defaults: None,
6296            snapshot,
6297            snapshot_path: std::path::PathBuf::new(),
6298        };
6299        regenerate_schema_from_snapshot(
6300            &mut preserve,
6301            crate::config::Dialect::Postgresql,
6302            Some(IntrospectCasing::Preserve),
6303        );
6304
6305        assert_eq!(
6306            preserve.schema_code,
6307            "\
6308//! Auto-generated PostgreSQL schema from introspection
6309//!
6310//! Schema introspected from filtered database objects
6311
6312use drizzle::postgres::prelude::*;
6313
6314#[PostgresTable]
6315pub struct AuditLogs {
6316    #[column(primary)]
6317    pub id: i32,
6318    pub user_name: String,
6319}
6320
6321#[derive(PostgresSchema)]
6322pub struct Schema {
6323    pub audit_logs: AuditLogs,
6324}
6325"
6326        );
6327    }
6328
6329    #[cfg(feature = "postgres-sync")]
6330    #[test]
6331    fn postgres_sync_migrate_applies_concurrent_index_without_transaction() {
6332        use drizzle_migrations::{Migration, Migrations};
6333        use drizzle_types::Dialect;
6334
6335        let creds = test_postgres_creds();
6336        let mut setup_client = match connect_postgres_sync(&creds) {
6337            Ok(c) => c,
6338            Err(e) => {
6339                eprintln!(
6340                    "Skipping postgres_sync_migrate_applies_concurrent_index_without_transaction: {}",
6341                    e
6342                );
6343                return;
6344            }
6345        };
6346
6347        let table = unique_pg_name("cli_sync_users");
6348        let index = format!("{}_email_idx", table);
6349        let migration_schema = unique_pg_name("cli_sync_mig");
6350
6351        setup_client
6352            .batch_execute(&format!(
6353                "DROP TABLE IF EXISTS \"{table}\" CASCADE; \
6354                 CREATE TABLE \"{table}\" (id integer, email text NOT NULL); \
6355                 INSERT INTO \"{table}\" (id, email) VALUES (1, 'a@example.com');"
6356            ))
6357            .expect("setup table for concurrent index test");
6358        drop(setup_client);
6359
6360        let migration_tag = format!("20260212000000_{table}");
6361        let migration_sql =
6362            format!("CREATE INDEX CONCURRENTLY \"{index}\" ON \"{table}\" (\"email\");");
6363        let set = Migrations::with_tracking(
6364            vec![Migration::new(&migration_tag, &migration_sql)],
6365            Dialect::PostgreSQL,
6366            drizzle_migrations::Tracking::POSTGRES.schema(migration_schema.clone()),
6367        );
6368
6369        let result = run_postgres_sync_migrations(&set, &creds, false)
6370            .expect("sync migration with concurrent index should succeed");
6371        assert_eq!(result.applied_count, 1);
6372
6373        let mut verify_client = connect_postgres_sync(&creds).expect("reconnect for verification");
6374        let exists: i64 = verify_client
6375            .query_one(
6376                "SELECT COUNT(*)::bigint FROM pg_indexes \
6377                 WHERE schemaname = 'public' AND tablename = $1 AND indexname = $2",
6378                &[&table, &index],
6379            )
6380            .expect("query pg_indexes")
6381            .get(0);
6382        assert_eq!(exists, 1, "concurrent index was not created");
6383
6384        let _ = verify_client.batch_execute(&format!(
6385            "DROP TABLE IF EXISTS \"{table}\" CASCADE; \
6386             DROP SCHEMA IF EXISTS \"{migration_schema}\" CASCADE;"
6387        ));
6388    }
6389
6390    #[cfg(feature = "postgres-sync")]
6391    #[test]
6392    fn postgres_sync_migrate_upgrades_legacy_tracking_table_and_applies_pending() {
6393        use drizzle_migrations::{Migration, Migrations};
6394        use drizzle_types::Dialect;
6395
6396        let creds = test_postgres_creds();
6397        let mut client = connect_postgres_sync(&creds)
6398            .expect("connect postgres for legacy tracking upgrade test");
6399
6400        let applied_table = unique_pg_name("cli_sync_applied");
6401        let pending_table = unique_pg_name("cli_sync_pending");
6402        let migration_schema = unique_pg_name("cli_sync_tracking");
6403
6404        client
6405            .batch_execute(&format!(
6406                "DROP TABLE IF EXISTS \"{applied_table}\" CASCADE; \
6407                 DROP TABLE IF EXISTS \"{pending_table}\" CASCADE; \
6408                 DROP SCHEMA IF EXISTS \"{migration_schema}\" CASCADE; \
6409                 CREATE SCHEMA \"{migration_schema}\"; \
6410                 CREATE TABLE \"{migration_schema}\".\"__drizzle_migrations\" (id SERIAL PRIMARY KEY, hash TEXT NOT NULL, created_at BIGINT); \
6411                 CREATE TABLE \"{applied_table}\" (id integer primary key);"
6412            ))
6413            .expect("setup legacy postgres tracking metadata");
6414
6415        let first = Migration::new(
6416            &format!("20230331141203_{applied_table}"),
6417            &format!("CREATE TABLE \"{applied_table}\" (id integer primary key);"),
6418        );
6419        let second = Migration::new(
6420            &format!("20230331141204_{pending_table}"),
6421            &format!("CREATE TABLE \"{pending_table}\" (id integer primary key);"),
6422        );
6423        let set = Migrations::with_tracking(
6424            vec![first.clone(), second.clone()],
6425            Dialect::PostgreSQL,
6426            drizzle_migrations::Tracking::POSTGRES.schema(migration_schema.clone()),
6427        );
6428
6429        client
6430            .execute(
6431                &format!(
6432                    "INSERT INTO \"{migration_schema}\".\"__drizzle_migrations\" (hash, created_at) VALUES ($1, $2)"
6433                ),
6434                &[&first.hash(), &first.created_at()],
6435            )
6436            .expect("insert legacy applied migration row");
6437        drop(client);
6438
6439        let result = run_postgres_sync_migrations(&set, &creds, false)
6440            .expect("sync migration upgrade should succeed");
6441        assert_eq!(result.applied_count, 1);
6442        assert_eq!(result.applied_migrations, vec![second.hash().to_string()]);
6443
6444        let mut verify_client = connect_postgres_sync(&creds).expect("reconnect for verification");
6445        let columns: Vec<String> = verify_client
6446            .query(
6447                "SELECT column_name FROM information_schema.columns WHERE table_schema = $1 AND table_name = '__drizzle_migrations' ORDER BY ordinal_position",
6448                &[&migration_schema],
6449            )
6450            .expect("query upgraded tracking columns")
6451            .into_iter()
6452            .map(|row| row.get(0))
6453            .collect();
6454        assert_eq!(
6455            columns,
6456            vec!["id", "hash", "created_at", "name", "applied_at"]
6457        );
6458
6459        let rows: Vec<(String, i64, String, Option<String>)> = verify_client
6460            .query(
6461                &format!(
6462                    "SELECT hash, created_at, name, applied_at::text FROM \"{migration_schema}\".\"__drizzle_migrations\" ORDER BY id ASC"
6463                ),
6464                &[],
6465            )
6466            .expect("query upgraded metadata rows")
6467            .into_iter()
6468            .map(|row| (row.get(0), row.get(1), row.get(2), row.get(3)))
6469            .collect();
6470        assert_eq!(rows.len(), 2);
6471        assert_eq!(rows[0].0, first.hash());
6472        assert_eq!(rows[0].1, first.created_at());
6473        assert_eq!(rows[0].2, first.name());
6474        assert!(
6475            rows[0].3.is_some(),
6476            "legacy rows get applied_at backfilled so they cannot read as interrupted"
6477        );
6478        assert_eq!(rows[1].0, second.hash());
6479        assert_eq!(rows[1].1, second.created_at());
6480        assert_eq!(rows[1].2, second.name());
6481        assert!(rows[1].3.is_some());
6482
6483        let pending_exists: i64 = verify_client
6484            .query_one(
6485                "SELECT COUNT(*)::bigint FROM information_schema.tables WHERE table_schema = 'public' AND table_name = $1",
6486                &[&pending_table],
6487            )
6488            .expect("query pending table")
6489            .get(0);
6490        assert_eq!(pending_exists, 1);
6491
6492        let _ = verify_client.batch_execute(&format!(
6493            "DROP TABLE IF EXISTS \"{applied_table}\" CASCADE; \
6494             DROP TABLE IF EXISTS \"{pending_table}\" CASCADE; \
6495             DROP SCHEMA IF EXISTS \"{migration_schema}\" CASCADE;"
6496        ));
6497    }
6498
6499    #[cfg(feature = "postgres-sync")]
6500    #[test]
6501    fn postgres_sync_migrate_upgrade_rejects_unmatched_legacy_rows() {
6502        use drizzle_migrations::{Migration, Migrations};
6503        use drizzle_types::Dialect;
6504
6505        let creds = test_postgres_creds();
6506        let mut client =
6507            connect_postgres_sync(&creds).expect("connect postgres for unmatched legacy row test");
6508
6509        let migration_schema = unique_pg_name("cli_sync_tracking_unmatched");
6510        client
6511            .batch_execute(&format!(
6512                "DROP SCHEMA IF EXISTS \"{migration_schema}\" CASCADE; \
6513                 CREATE SCHEMA \"{migration_schema}\"; \
6514                 CREATE TABLE \"{migration_schema}\".\"__drizzle_migrations\" (id SERIAL PRIMARY KEY, hash TEXT NOT NULL, created_at BIGINT);"
6515            ))
6516            .expect("setup unmatched legacy metadata");
6517
6518        let migration = Migration::new(
6519            "20230331141203_cli_sync_first",
6520            "CREATE TABLE \"cli_sync_unmatched_target\" (id integer primary key);",
6521        );
6522        let set = Migrations::with_tracking(
6523            vec![migration],
6524            Dialect::PostgreSQL,
6525            drizzle_migrations::Tracking::POSTGRES.schema(migration_schema.clone()),
6526        );
6527
6528        client
6529            .execute(
6530                &format!(
6531                    "INSERT INTO \"{migration_schema}\".\"__drizzle_migrations\" (hash, created_at) VALUES ($1, $2)"
6532                ),
6533                &[&"unknown_hash", &1_680_271_924_000_i64],
6534            )
6535            .expect("insert unmatched legacy row");
6536        drop(client);
6537
6538        let err = run_postgres_sync_migrations(&set, &creds, false)
6539            .expect_err("unmatched legacy metadata should fail");
6540        assert!(err.to_string().contains("do not match local migrations"));
6541
6542        let mut verify_client = connect_postgres_sync(&creds).expect("reconnect for verification");
6543        let columns: Vec<String> = verify_client
6544            .query(
6545                "SELECT column_name FROM information_schema.columns WHERE table_schema = $1 AND table_name = '__drizzle_migrations' ORDER BY ordinal_position",
6546                &[&migration_schema],
6547            )
6548            .expect("query legacy tracking columns")
6549            .into_iter()
6550            .map(|row| row.get(0))
6551            .collect();
6552        assert_eq!(columns, vec!["id", "hash", "created_at"]);
6553
6554        let _ = verify_client.batch_execute(&format!(
6555            "DROP TABLE IF EXISTS \"cli_sync_unmatched_target\" CASCADE; \
6556             DROP SCHEMA IF EXISTS \"{migration_schema}\" CASCADE;"
6557        ));
6558    }
6559
6560    #[cfg(feature = "tokio-postgres")]
6561    #[test]
6562    fn tokio_postgres_migrate_applies_concurrent_index_without_transaction() {
6563        use drizzle_migrations::{Migration, Migrations};
6564        use drizzle_types::Dialect;
6565
6566        let creds = test_postgres_creds();
6567        let rt = tokio::runtime::Builder::new_current_thread()
6568            .enable_all()
6569            .build()
6570            .expect("create tokio runtime");
6571
6572        rt.block_on(async {
6573            let client = match connect_postgres_async(&creds).await {
6574                Ok(client) => client,
6575                Err(e) => {
6576                    eprintln!(
6577                        "Skipping tokio_postgres_migrate_applies_concurrent_index_without_transaction: {}",
6578                        e
6579                    );
6580                    return;
6581                }
6582            };
6583
6584            let table = unique_pg_name("cli_async_users");
6585            let index = format!("{}_email_idx", table);
6586            let migration_schema = unique_pg_name("cli_async_mig");
6587
6588            client
6589                .batch_execute(&format!(
6590                    "DROP TABLE IF EXISTS \"{table}\" CASCADE; \
6591                     CREATE TABLE \"{table}\" (id integer, email text NOT NULL); \
6592                     INSERT INTO \"{table}\" (id, email) VALUES (1, 'a@example.com');"
6593                ))
6594                .await
6595                .expect("setup table for async concurrent index test");
6596
6597            let migration_tag = format!("20260212000000_{table}");
6598            let migration_sql = format!(
6599                "CREATE INDEX CONCURRENTLY \"{index}\" ON \"{table}\" (\"email\");"
6600            );
6601            let set = Migrations::with_tracking(
6602                vec![Migration::new(&migration_tag, &migration_sql)],
6603                Dialect::PostgreSQL,
6604                drizzle_migrations::Tracking::POSTGRES.schema(migration_schema.clone()),
6605            );
6606
6607            let result = run_postgres_async_inner(&set, &creds, false)
6608                .await
6609                .expect("async migration with concurrent index should succeed");
6610            assert_eq!(result.applied_count, 1);
6611
6612            let exists: i64 = client
6613                .query_one(
6614                    "SELECT COUNT(*)::bigint FROM pg_indexes \
6615                     WHERE schemaname = 'public' AND tablename = $1 AND indexname = $2",
6616                    &[&table, &index],
6617                )
6618                .await
6619                .expect("query pg_indexes")
6620                .get(0);
6621            assert_eq!(exists, 1, "async concurrent index was not created");
6622
6623            let _ = client
6624                .batch_execute(&format!(
6625                    "DROP TABLE IF EXISTS \"{table}\" CASCADE; \
6626                     DROP SCHEMA IF EXISTS \"{migration_schema}\" CASCADE;"
6627                ))
6628                .await;
6629        });
6630    }
6631
6632    #[cfg(feature = "tokio-postgres")]
6633    #[test]
6634    fn tokio_postgres_migrate_upgrades_legacy_tracking_table_and_applies_pending() {
6635        use drizzle_migrations::{Migration, Migrations};
6636        use drizzle_types::Dialect;
6637
6638        let creds = test_postgres_creds();
6639        let rt = tokio::runtime::Builder::new_current_thread()
6640            .enable_all()
6641            .build()
6642            .expect("create tokio runtime");
6643
6644        rt.block_on(async {
6645            let client = connect_postgres_async(&creds)
6646                .await
6647                .expect("connect tokio-postgres for legacy tracking upgrade test");
6648
6649            let applied_table = unique_pg_name("cli_async_applied");
6650            let pending_table = unique_pg_name("cli_async_pending");
6651            let migration_schema = unique_pg_name("cli_async_tracking");
6652
6653            client
6654                .batch_execute(&format!(
6655                    "DROP TABLE IF EXISTS \"{applied_table}\" CASCADE; \
6656                     DROP TABLE IF EXISTS \"{pending_table}\" CASCADE; \
6657                     DROP SCHEMA IF EXISTS \"{migration_schema}\" CASCADE; \
6658                     CREATE SCHEMA \"{migration_schema}\"; \
6659                     CREATE TABLE \"{migration_schema}\".\"__drizzle_migrations\" (id SERIAL PRIMARY KEY, hash TEXT NOT NULL, created_at BIGINT); \
6660                     CREATE TABLE \"{applied_table}\" (id integer primary key);"
6661                ))
6662                .await
6663                .expect("setup legacy postgres tracking metadata");
6664
6665            let first = Migration::new(
6666                &format!("20230331141203_{applied_table}"),
6667                &format!("CREATE TABLE \"{applied_table}\" (id integer primary key);"),
6668            );
6669            let second = Migration::new(
6670                &format!("20230331141204_{pending_table}"),
6671                &format!("CREATE TABLE \"{pending_table}\" (id integer primary key);"),
6672            );
6673            let set = Migrations::with_tracking(
6674                vec![first.clone(), second.clone()],
6675                Dialect::PostgreSQL,
6676                drizzle_migrations::Tracking::POSTGRES.schema(migration_schema.clone()),
6677            );
6678
6679            client
6680                .execute(
6681                    &format!(
6682                        "INSERT INTO \"{migration_schema}\".\"__drizzle_migrations\" (hash, created_at) VALUES ($1, $2)"
6683                    ),
6684                    &[&first.hash(), &first.created_at()],
6685                )
6686                .await
6687                .expect("insert legacy applied migration row");
6688
6689            let result = run_postgres_async_inner(&set, &creds, false)
6690                .await
6691                .expect("async migration upgrade should succeed");
6692            assert_eq!(result.applied_count, 1);
6693            assert_eq!(result.applied_migrations, vec![second.hash().to_string()]);
6694
6695            let columns: Vec<String> = client
6696                .query(
6697                    "SELECT column_name FROM information_schema.columns WHERE table_schema = $1 AND table_name = '__drizzle_migrations' ORDER BY ordinal_position",
6698                    &[&migration_schema],
6699                )
6700                .await
6701                .expect("query upgraded tracking columns")
6702                .into_iter()
6703                .map(|row| row.get(0))
6704                .collect();
6705            assert_eq!(
6706                columns,
6707                vec!["id", "hash", "created_at", "name", "applied_at"]
6708            );
6709
6710            let rows: Vec<(String, i64, String, Option<String>)> = client
6711                .query(
6712                    &format!(
6713                        "SELECT hash, created_at, name, applied_at::text FROM \"{migration_schema}\".\"__drizzle_migrations\" ORDER BY id ASC"
6714                    ),
6715                    &[],
6716                )
6717                .await
6718                .expect("query upgraded metadata rows")
6719                .into_iter()
6720                .map(|row| (row.get(0), row.get(1), row.get(2), row.get(3)))
6721                .collect();
6722            assert_eq!(rows.len(), 2);
6723            assert_eq!(rows[0].0, first.hash());
6724            assert_eq!(rows[0].1, first.created_at());
6725            assert_eq!(rows[0].2, first.name());
6726            assert!(
6727                rows[0].3.is_some(),
6728                "legacy rows get applied_at backfilled so they cannot read as interrupted"
6729            );
6730            assert_eq!(rows[1].0, second.hash());
6731            assert_eq!(rows[1].1, second.created_at());
6732            assert_eq!(rows[1].2, second.name());
6733            assert!(rows[1].3.is_some());
6734
6735            let pending_exists: i64 = client
6736                .query_one(
6737                    "SELECT COUNT(*)::bigint FROM information_schema.tables WHERE table_schema = 'public' AND table_name = $1",
6738                    &[&pending_table],
6739                )
6740                .await
6741                .expect("query pending table")
6742                .get(0);
6743            assert_eq!(pending_exists, 1);
6744
6745            let _ = client
6746                .batch_execute(&format!(
6747                    "DROP TABLE IF EXISTS \"{applied_table}\" CASCADE; \
6748                     DROP TABLE IF EXISTS \"{pending_table}\" CASCADE; \
6749                     DROP SCHEMA IF EXISTS \"{migration_schema}\" CASCADE;"
6750                ))
6751                .await;
6752        });
6753    }
6754}