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