Skip to main content

prax_cli/commands/
introspect.rs

1//! Database introspection implementation.
2//!
3//! This module provides the actual database introspection functionality
4//! using the `prax-query` introspection types.
5
6use prax_query::introspection::{
7    ColumnInfo, DatabaseSchema, ForeignKeyInfo, IndexColumn, IndexInfo, ReferentialAction,
8    SortOrder, TableInfo, generate_prax_schema, normalize_type, queries,
9};
10// `ViewInfo` is only constructed by the PostgreSQL introspector (the other
11// backends build views differently or not at all); `EnumInfo`/`NormalizedType`
12// are also needed by MySQL, which synthesizes one enum per `enum(...)`
13// column. Import each only when the relevant feature is compiled to keep
14// single-feature builds clean.
15#[cfg(any(feature = "postgres", feature = "mysql"))]
16use prax_query::introspection::EnumInfo;
17#[cfg(feature = "mysql")]
18use prax_query::introspection::NormalizedType;
19#[cfg(feature = "postgres")]
20use prax_query::introspection::ViewInfo;
21use prax_query::sql::DatabaseType;
22
23use crate::config::Config;
24use crate::error::{CliError, CliResult};
25
26/// Introspection options.
27#[derive(Debug, Clone)]
28pub struct IntrospectionOptions {
29    /// Schema/namespace to introspect.
30    pub schema: Option<String>,
31    /// Include views.
32    pub include_views: bool,
33    /// Include materialized views.
34    pub include_materialized_views: bool,
35    /// Table filter pattern.
36    pub table_filter: Option<String>,
37    /// Tables to exclude.
38    pub exclude_pattern: Option<String>,
39    /// Include comments.
40    pub include_comments: bool,
41    /// Sample size for MongoDB.
42    pub sample_size: usize,
43}
44
45impl Default for IntrospectionOptions {
46    fn default() -> Self {
47        Self {
48            schema: None,
49            include_views: false,
50            include_materialized_views: false,
51            table_filter: None,
52            exclude_pattern: None,
53            include_comments: true,
54            sample_size: 100,
55        }
56    }
57}
58
59/// Database introspector trait.
60#[allow(async_fn_in_trait)]
61pub trait Introspector {
62    /// Introspect the database and return schema information.
63    async fn introspect(&self, options: &IntrospectionOptions) -> CliResult<DatabaseSchema>;
64}
65
66/// Get the database type from provider string.
67pub fn get_database_type(provider: &str) -> CliResult<DatabaseType> {
68    match provider.to_lowercase().as_str() {
69        "postgresql" | "postgres" | "pg" => Ok(DatabaseType::PostgreSQL),
70        "mysql" | "mariadb" => Ok(DatabaseType::MySQL),
71        "sqlite" | "sqlite3" => Ok(DatabaseType::SQLite),
72        "mssql" | "sqlserver" | "sql_server" => Ok(DatabaseType::MSSQL),
73        _ => Err(CliError::Config(format!(
74            "Unsupported database provider: {}",
75            provider
76        ))),
77    }
78}
79
80/// Get default schema for database type.
81pub fn default_schema(db_type: DatabaseType) -> &'static str {
82    match db_type {
83        DatabaseType::PostgreSQL => "public",
84        DatabaseType::MySQL => "",
85        DatabaseType::SQLite => "",
86        DatabaseType::MSSQL => "dbo",
87    }
88}
89
90/// Introspect a database, dispatching to the backend matching `provider`.
91///
92/// This is the single entry point shared by `db pull` and the migration
93/// engine's `resolve_source_schema`. Each backend is behind its cargo
94/// feature; a provider whose feature was not compiled in returns a clear
95/// `Config` error rather than silently doing nothing.
96pub async fn introspect_database(
97    provider: &str,
98    database_url: &str,
99    options: &IntrospectionOptions,
100) -> CliResult<DatabaseSchema> {
101    let db_type = get_database_type(provider)?;
102    match db_type {
103        DatabaseType::PostgreSQL => {
104            #[cfg(feature = "postgres")]
105            {
106                postgres::PostgresIntrospector::new(database_url.to_string())
107                    .introspect(options)
108                    .await
109            }
110            #[cfg(not(feature = "postgres"))]
111            {
112                let _ = (database_url, options);
113                Err(CliError::FeatureUnavailable(
114                    "PostgreSQL introspection requires the `postgres` feature: rebuild with \
115                     --features postgres."
116                        .to_string(),
117                ))
118            }
119        }
120        DatabaseType::MySQL => {
121            #[cfg(feature = "mysql")]
122            {
123                mysql::MysqlIntrospector::new(database_url.to_string())
124                    .introspect(options)
125                    .await
126            }
127            #[cfg(not(feature = "mysql"))]
128            {
129                let _ = (database_url, options);
130                Err(CliError::FeatureUnavailable(
131                    "MySQL introspection requires the `mysql` feature: rebuild with \
132                     --features mysql."
133                        .to_string(),
134                ))
135            }
136        }
137        DatabaseType::SQLite => {
138            #[cfg(feature = "sqlite")]
139            {
140                sqlite::SqliteIntrospector::new(database_url.to_string())
141                    .introspect(options)
142                    .await
143            }
144            #[cfg(not(feature = "sqlite"))]
145            {
146                let _ = (database_url, options);
147                Err(CliError::FeatureUnavailable(
148                    "SQLite introspection requires the `sqlite` feature: rebuild with \
149                     --features sqlite."
150                        .to_string(),
151                ))
152            }
153        }
154        DatabaseType::MSSQL => {
155            #[cfg(feature = "mssql")]
156            {
157                mssql::MssqlIntrospector::new(database_url.to_string())
158                    .introspect(options)
159                    .await
160            }
161            #[cfg(not(feature = "mssql"))]
162            {
163                let _ = (database_url, options);
164                Err(CliError::FeatureUnavailable(
165                    "MSSQL introspection requires the `mssql` feature: rebuild with \
166                     --features mssql."
167                        .to_string(),
168                ))
169            }
170        }
171    }
172}
173
174// ============================================================================
175// PostgreSQL Introspector
176// ============================================================================
177
178#[cfg(feature = "postgres")]
179pub mod postgres {
180    use std::collections::HashMap;
181
182    use super::*;
183    use tokio_postgres::{Client, NoTls, Row};
184
185    /// PostgreSQL introspector.
186    pub struct PostgresIntrospector {
187        connection_string: String,
188    }
189
190    impl PostgresIntrospector {
191        /// Create a new PostgreSQL introspector.
192        pub fn new(connection_string: String) -> Self {
193            Self { connection_string }
194        }
195
196        /// Connect to the database.
197        async fn connect(&self) -> CliResult<Client> {
198            // Parse the DSN the same way tokio-postgres will, so the sslmode
199            // it carries is honored: anything but `disable` goes through the
200            // workspace's shared rustls connector (chain + hostname verified
201            // against the Mozilla root store). `prefer` still falls back to
202            // plaintext when the server declines TLS.
203            let config = self
204                .connection_string
205                .parse::<tokio_postgres::Config>()
206                .map_err(|e| CliError::Config(format!("Invalid connection string: {}", e)))?;
207
208            let tls_disabled = matches!(
209                config.get_ssl_mode(),
210                tokio_postgres::config::SslMode::Disable
211            );
212
213            if tls_disabled && !config.get_hosts().iter().all(is_local_host) {
214                crate::output::warn(
215                    "sslmode=disable with a non-local host: credentials and data will be \
216                     sent in plaintext.",
217                );
218            }
219
220            // The two connector types produce different `Connection`
221            // generics, so drive each arm independently and unify on the
222            // stream-agnostic `Client`. Each connect is bounded by
223            // `INTROSPECT_CONNECT_TIMEOUT_SECS` so an unreachable-but-not-
224            // refused host does not hang the CLI (parity with the pool-based
225            // backends).
226            let connect_timeout =
227                std::time::Duration::from_secs(super::INTROSPECT_CONNECT_TIMEOUT_SECS);
228            let client = if tls_disabled {
229                let (client, connection) = tokio::time::timeout(
230                    connect_timeout,
231                    tokio_postgres::connect(&self.connection_string, NoTls),
232                )
233                .await
234                .map_err(|_| {
235                    CliError::Unreachable("Failed to connect: connection timed out".to_string())
236                })?
237                .map_err(|e| CliError::Unreachable(format!("Failed to connect: {}", e)))?;
238                tokio::spawn(async move {
239                    if let Err(e) = connection.await {
240                        eprintln!("Connection error: {}", e);
241                    }
242                });
243                client
244            } else {
245                let (client, connection) = tokio::time::timeout(
246                    connect_timeout,
247                    tokio_postgres::connect(
248                        &self.connection_string,
249                        prax_postgres::tls::make_tls_connector(),
250                    ),
251                )
252                .await
253                .map_err(|_| {
254                    CliError::Unreachable("Failed to connect: connection timed out".to_string())
255                })?
256                .map_err(|e| CliError::Unreachable(format!("Failed to connect: {}", e)))?;
257                tokio::spawn(async move {
258                    if let Err(e) = connection.await {
259                        eprintln!("Connection error: {}", e);
260                    }
261                });
262                client
263            };
264
265            Ok(client)
266        }
267    }
268
269    impl Introspector for PostgresIntrospector {
270        async fn introspect(&self, options: &IntrospectionOptions) -> CliResult<DatabaseSchema> {
271            let client = self.connect().await?;
272            let schema_name = options.schema.as_deref().unwrap_or("public");
273
274            let mut db_schema = DatabaseSchema {
275                name: "database".to_string(),
276                schema: Some(schema_name.to_string()),
277                ..Default::default()
278            };
279
280            // Get tables
281            let tables_sql = queries::tables_query(DatabaseType::PostgreSQL, Some(schema_name));
282            let table_rows = client
283                .query(&tables_sql, &[])
284                .await
285                .map_err(|e| CliError::Database(format!("Failed to query tables: {}", e)))?;
286
287            for row in table_rows {
288                let table_name: String = row.get(0);
289
290                // Apply filters
291                if let Some(ref pattern) = options.table_filter
292                    && !matches_pattern(&table_name, pattern)
293                {
294                    continue;
295                }
296                if let Some(ref exclude) = options.exclude_pattern
297                    && matches_pattern(&table_name, exclude)
298                {
299                    continue;
300                }
301
302                let comment: Option<String> = row.try_get(1).ok();
303
304                db_schema.tables.push(TableInfo {
305                    name: table_name,
306                    schema: Some(schema_name.to_string()),
307                    comment: if options.include_comments {
308                        comment
309                    } else {
310                        None
311                    },
312                    ..Default::default()
313                });
314            }
315
316            // Fetch columns, primary keys, foreign keys, and indexes for the
317            // whole schema in one query each, then group rows by table in
318            // memory: 4 round-trips total instead of 4 per table. The table
319            // name is appended as the last selected column and used as the
320            // first ORDER BY key so grouped rows keep the exact per-table
321            // ordering of the original per-table queries.
322            let cols_sql = "SELECT \
323                    c.column_name, \
324                    c.data_type, \
325                    c.udt_name, \
326                    c.is_nullable = 'YES' as nullable, \
327                    c.column_default, \
328                    c.character_maximum_length, \
329                    c.numeric_precision, \
330                    c.numeric_scale, \
331                    col_description((quote_ident(c.table_schema) || '.' || quote_ident(c.table_name))::regclass, c.ordinal_position) as comment, \
332                    CASE WHEN c.column_default LIKE 'nextval%' THEN true ELSE false END as auto_increment, \
333                    c.table_name \
334                 FROM information_schema.columns c \
335                 WHERE c.table_schema = $1 \
336                 ORDER BY c.table_name, c.ordinal_position";
337            let col_rows = client
338                .query(cols_sql, &[&schema_name])
339                .await
340                .map_err(|e| CliError::Database(format!("Failed to query columns: {}", e)))?;
341
342            let mut columns_by_table: HashMap<String, Vec<Row>> = HashMap::new();
343            for col_row in col_rows {
344                let table_name: String = col_row.get(10);
345                columns_by_table
346                    .entry(table_name)
347                    .or_default()
348                    .push(col_row);
349            }
350
351            let pk_sql = "SELECT a.attname as column_name, c.relname as table_name \
352                 FROM pg_index i \
353                 JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey) \
354                 JOIN pg_class c ON c.oid = i.indrelid \
355                 JOIN pg_namespace n ON n.oid = c.relnamespace \
356                 WHERE i.indisprimary AND n.nspname = $1 \
357                 ORDER BY c.relname, array_position(i.indkey, a.attnum)";
358            let pk_rows = client
359                .query(pk_sql, &[&schema_name])
360                .await
361                .map_err(|e| CliError::Database(format!("Failed to query primary keys: {}", e)))?;
362
363            let mut pks_by_table: HashMap<String, Vec<Row>> = HashMap::new();
364            for pk_row in pk_rows {
365                let table_name: String = pk_row.get(1);
366                pks_by_table.entry(table_name).or_default().push(pk_row);
367            }
368
369            let fk_sql = "SELECT \
370                    tc.constraint_name, \
371                    kcu.column_name, \
372                    ccu.table_name as referenced_table, \
373                    ccu.table_schema as referenced_schema, \
374                    ccu.column_name as referenced_column, \
375                    rc.delete_rule, \
376                    rc.update_rule, \
377                    tc.table_name \
378                 FROM information_schema.table_constraints tc \
379                 JOIN information_schema.key_column_usage kcu ON tc.constraint_name = kcu.constraint_name \
380                 JOIN information_schema.constraint_column_usage ccu ON ccu.constraint_name = tc.constraint_name \
381                 JOIN information_schema.referential_constraints rc ON rc.constraint_name = tc.constraint_name \
382                 WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_schema = $1 \
383                 ORDER BY tc.table_name, tc.constraint_name, kcu.ordinal_position";
384            let fk_rows = client
385                .query(fk_sql, &[&schema_name])
386                .await
387                .map_err(|e| CliError::Database(format!("Failed to query foreign keys: {}", e)))?;
388
389            let mut fks_by_table: HashMap<String, Vec<Row>> = HashMap::new();
390            for fk_row in fk_rows {
391                let table_name: String = fk_row.get(7);
392                fks_by_table.entry(table_name).or_default().push(fk_row);
393            }
394
395            let idx_sql = "SELECT \
396                    i.relname as index_name, \
397                    a.attname as column_name, \
398                    ix.indisunique as is_unique, \
399                    ix.indisprimary as is_primary, \
400                    am.amname as index_type, \
401                    pg_get_expr(ix.indpred, ix.indrelid) as filter, \
402                    t.relname as table_name \
403                 FROM pg_index ix \
404                 JOIN pg_class t ON t.oid = ix.indrelid \
405                 JOIN pg_class i ON i.oid = ix.indexrelid \
406                 JOIN pg_namespace n ON n.oid = t.relnamespace \
407                 JOIN pg_am am ON i.relam = am.oid \
408                 JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(ix.indkey) \
409                 WHERE n.nspname = $1 \
410                 ORDER BY t.relname, i.relname, array_position(ix.indkey, a.attnum)";
411            let idx_rows = client
412                .query(idx_sql, &[&schema_name])
413                .await
414                .map_err(|e| CliError::Database(format!("Failed to query indexes: {}", e)))?;
415
416            let mut indexes_by_table: HashMap<String, Vec<Row>> = HashMap::new();
417            for idx_row in idx_rows {
418                let table_name: String = idx_row.get(6);
419                indexes_by_table
420                    .entry(table_name)
421                    .or_default()
422                    .push(idx_row);
423            }
424
425            // Populate each table from the pre-fetched rows.
426            for table in &mut db_schema.tables {
427                for col_row in columns_by_table.remove(&table.name).unwrap_or_default() {
428                    let col_name: String = col_row.get(0);
429                    let data_type: String = col_row.get(1);
430                    let udt_name: String = col_row.get(2);
431                    let nullable: bool = col_row.get(3);
432                    let default: Option<String> = col_row.try_get(4).ok();
433                    let max_length: Option<i32> = col_row.try_get(5).ok();
434                    let precision: Option<i32> = col_row.try_get(6).ok();
435                    let scale: Option<i32> = col_row.try_get(7).ok();
436                    let comment: Option<String> = col_row.try_get(8).ok();
437                    let auto_increment: bool = col_row.try_get(9).unwrap_or(false);
438
439                    let normalized = normalize_type(
440                        DatabaseType::PostgreSQL,
441                        &udt_name,
442                        max_length,
443                        precision,
444                        scale,
445                    );
446
447                    table.columns.push(ColumnInfo {
448                        name: col_name,
449                        db_type: data_type,
450                        normalized_type: normalized,
451                        nullable,
452                        default,
453                        auto_increment,
454                        max_length,
455                        precision,
456                        scale,
457                        comment: if options.include_comments {
458                            comment
459                        } else {
460                            None
461                        },
462                        ..Default::default()
463                    });
464                }
465
466                for pk_row in pks_by_table.remove(&table.name).unwrap_or_default() {
467                    let col_name: String = pk_row.get(0);
468                    table.primary_key.push(col_name.clone());
469
470                    // Mark column as primary key
471                    if let Some(col) = table.columns.iter_mut().find(|c| c.name == col_name) {
472                        col.is_primary_key = true;
473                    }
474                }
475
476                let mut fk_map: HashMap<String, ForeignKeyInfo> = HashMap::new();
477                for fk_row in fks_by_table.remove(&table.name).unwrap_or_default() {
478                    let constraint_name: String = fk_row.get(0);
479                    let column_name: String = fk_row.get(1);
480                    let ref_table: String = fk_row.get(2);
481                    let ref_schema: Option<String> = fk_row.try_get(3).ok();
482                    let ref_column: String = fk_row.get(4);
483                    let delete_rule: String = fk_row.get(5);
484                    let update_rule: String = fk_row.get(6);
485
486                    let fk =
487                        fk_map
488                            .entry(constraint_name.clone())
489                            .or_insert_with(|| ForeignKeyInfo {
490                                name: constraint_name,
491                                columns: Vec::new(),
492                                referenced_table: ref_table,
493                                referenced_schema: ref_schema,
494                                referenced_columns: Vec::new(),
495                                on_delete: ReferentialAction::from_str(&delete_rule),
496                                on_update: ReferentialAction::from_str(&update_rule),
497                            });
498
499                    fk.columns.push(column_name);
500                    fk.referenced_columns.push(ref_column);
501                }
502
503                table.foreign_keys = fk_map.into_values().collect();
504
505                let mut idx_map: HashMap<String, IndexInfo> = HashMap::new();
506                for idx_row in indexes_by_table.remove(&table.name).unwrap_or_default() {
507                    let idx_name: String = idx_row.get(0);
508                    let col_name: String = idx_row.get(1);
509                    let is_unique: bool = idx_row.get(2);
510                    let is_primary: bool = idx_row.get(3);
511                    let idx_type: Option<String> = idx_row.try_get(4).ok();
512                    let filter: Option<String> = idx_row.try_get(5).ok();
513
514                    let idx = idx_map
515                        .entry(idx_name.clone())
516                        .or_insert_with(|| IndexInfo {
517                            name: idx_name,
518                            columns: Vec::new(),
519                            is_unique,
520                            is_primary,
521                            index_type: idx_type,
522                            filter,
523                        });
524
525                    idx.columns.push(IndexColumn {
526                        name: col_name,
527                        order: SortOrder::Asc,
528                        ..Default::default()
529                    });
530                }
531
532                table.indexes = idx_map.into_values().collect();
533            }
534
535            // Get enums
536            let enums_sql = queries::enums_query(Some(schema_name));
537            let enum_rows = client
538                .query(&enums_sql, &[])
539                .await
540                .map_err(|e| CliError::Database(format!("Failed to query enums: {}", e)))?;
541
542            let mut enum_map: HashMap<String, EnumInfo> = HashMap::new();
543            for enum_row in enum_rows {
544                let enum_name: String = enum_row.get(0);
545                let enum_value: String = enum_row.get(1);
546
547                let enum_info = enum_map
548                    .entry(enum_name.clone())
549                    .or_insert_with(|| EnumInfo {
550                        name: enum_name,
551                        schema: Some(schema_name.to_string()),
552                        values: Vec::new(),
553                    });
554
555                enum_info.values.push(enum_value);
556            }
557
558            db_schema.enums = enum_map.into_values().collect();
559
560            // Get views
561            if options.include_views || options.include_materialized_views {
562                let views_sql = queries::views_query(DatabaseType::PostgreSQL, Some(schema_name));
563                let view_rows = client
564                    .query(&views_sql, &[])
565                    .await
566                    .map_err(|e| CliError::Database(format!("Failed to query views: {}", e)))?;
567
568                for view_row in view_rows {
569                    let view_name: String = view_row.get(0);
570                    let definition: Option<String> = view_row.try_get(1).ok();
571                    let is_materialized: bool = view_row.get(2);
572
573                    if is_materialized && !options.include_materialized_views {
574                        continue;
575                    }
576                    if !is_materialized && !options.include_views {
577                        continue;
578                    }
579
580                    db_schema.views.push(ViewInfo {
581                        name: view_name,
582                        schema: Some(schema_name.to_string()),
583                        definition,
584                        is_materialized,
585                        columns: Vec::new(),
586                    });
587                }
588            }
589
590            Ok(db_schema)
591        }
592    }
593
594    /// Whether a parsed DSN host is local (loopback TCP or a Unix socket).
595    fn is_local_host(host: &tokio_postgres::config::Host) -> bool {
596        match host {
597            tokio_postgres::config::Host::Tcp(name) => {
598                name == "localhost" || name == "127.0.0.1" || name == "::1"
599            }
600            tokio_postgres::config::Host::Unix(_) => true,
601        }
602    }
603}
604
605// ============================================================================
606// Shared helpers for JSON-row backends (MySQL)
607// ============================================================================
608
609/// Bounded connect timeout for introspection pools. Introspection is a
610/// short-lived, interactive step; without a bound an unreachable-but-not-
611/// refused host (e.g. a firewall drop) would hang the CLI. `migrate dev`
612/// relies on this so it can fall back to greenfield when a DB is unreachable.
613#[cfg(any(
614    feature = "postgres",
615    feature = "mysql",
616    feature = "sqlite",
617    feature = "mssql"
618))]
619pub(crate) const INTROSPECT_CONNECT_TIMEOUT_SECS: u64 = 5;
620
621/// A source of introspection rows returned as JSON objects, keyed by column
622/// name. Implemented for the raw engines of the JSON-capable backends so the
623/// MySQL and SQLite introspectors share one row-fetch shim instead of each
624/// hand-rolling `raw_sql_query(sql, &[]) -> into_json`.
625#[cfg(any(feature = "mysql", feature = "sqlite"))]
626trait JsonRowSource {
627    /// Run `sql` (no bind params) and return each row as a JSON object.
628    async fn json_rows(&self, sql: &str) -> CliResult<Vec<serde_json::Value>>;
629}
630
631#[cfg(feature = "mysql")]
632impl JsonRowSource for prax_mysql::MysqlRawEngine {
633    async fn json_rows(&self, sql: &str) -> CliResult<Vec<serde_json::Value>> {
634        let rows = self
635            .raw_sql_query(sql, &[])
636            .await
637            .map_err(|e| CliError::Database(format!("Introspection query failed: {}", e)))?;
638        Ok(rows.into_iter().map(|r| r.into_json()).collect())
639    }
640}
641
642#[cfg(feature = "sqlite")]
643impl JsonRowSource for prax_sqlite::SqliteRawEngine {
644    async fn json_rows(&self, sql: &str) -> CliResult<Vec<serde_json::Value>> {
645        let rows = self
646            .raw_sql_query(sql, &[])
647            .await
648            .map_err(|e| CliError::Database(format!("Introspection query failed: {}", e)))?;
649        Ok(rows.into_iter().map(|r| r.into_json()).collect())
650    }
651}
652
653/// Simple glob-style pattern matching shared across introspectors.
654///
655/// Supported subset: `*` (match all), `pre*` (prefix), `*suf` (suffix),
656/// `*mid*` (substring/contains). Interior wildcards (e.g. `a*b*c` or
657/// `pre*suf`) are **not** supported — such a pattern falls through to an
658/// exact-string compare and will typically match nothing. Callers should
659/// stick to the four supported shapes for table include/exclude filters.
660#[cfg(any(
661    feature = "postgres",
662    feature = "mysql",
663    feature = "sqlite",
664    feature = "mssql"
665))]
666fn matches_pattern(name: &str, pattern: &str) -> bool {
667    if pattern == "*" {
668        return true;
669    }
670
671    if pattern.starts_with('*') && pattern.ends_with('*') {
672        let middle = &pattern[1..pattern.len() - 1];
673        return name.contains(middle);
674    }
675
676    if let Some(suffix) = pattern.strip_prefix('*') {
677        return name.ends_with(suffix);
678    }
679
680    if let Some(prefix) = pattern.strip_suffix('*') {
681        return name.starts_with(prefix);
682    }
683
684    name == pattern
685}
686
687/// Look up a column in a JSON object row case-insensitively.
688///
689/// MySQL's `information_schema` returns unaliased column names in uppercase
690/// (e.g. `TABLE_NAME`) while aliased expressions keep the alias case, so a
691/// single query row can mix cases. Match the exact key first, then fall back
692/// to a case-insensitive scan.
693#[cfg(any(feature = "mysql", feature = "sqlite"))]
694fn json_get<'a>(row: &'a serde_json::Value, key: &str) -> Option<&'a serde_json::Value> {
695    if let Some(v) = row.get(key) {
696        return Some(v);
697    }
698    row.as_object()?
699        .iter()
700        .find(|(k, _)| k.eq_ignore_ascii_case(key))
701        .map(|(_, v)| v)
702}
703
704/// Read an optional string column from a serde_json object row.
705#[cfg(any(feature = "mysql", feature = "sqlite"))]
706fn json_str(row: &serde_json::Value, key: &str) -> Option<String> {
707    json_get(row, key)
708        .and_then(|v| v.as_str())
709        .map(str::to_string)
710}
711
712/// Read a boolean column from a JSON object row, treating MySQL's 1/0 and
713/// "YES"/"NO" forms as booleans. Only the MySQL introspector needs this;
714/// SQLite reads its PRAGMA booleans via `json_i32(...) != 0`.
715#[cfg(feature = "mysql")]
716fn json_bool(row: &serde_json::Value, key: &str) -> bool {
717    match json_get(row, key) {
718        Some(serde_json::Value::Bool(b)) => *b,
719        Some(serde_json::Value::Number(n)) => n.as_i64().is_some_and(|i| i != 0),
720        Some(serde_json::Value::String(s)) => {
721            matches!(s.as_str(), "1" | "YES" | "yes" | "true" | "TRUE")
722        }
723        _ => false,
724    }
725}
726
727/// Read an integer column from a JSON object row.
728#[cfg(any(feature = "mysql", feature = "sqlite"))]
729fn json_i32(row: &serde_json::Value, key: &str) -> Option<i32> {
730    match json_get(row, key) {
731        Some(serde_json::Value::Number(n)) => n.as_i64().and_then(|i| i32::try_from(i).ok()),
732        Some(serde_json::Value::String(s)) => s.parse::<i32>().ok(),
733        _ => None,
734    }
735}
736
737// ============================================================================
738// MySQL Introspector
739// ============================================================================
740
741#[cfg(feature = "mysql")]
742pub mod mysql {
743    use std::collections::HashSet;
744
745    use super::*;
746    use prax_mysql::{MysqlPool, MysqlRawEngine};
747
748    /// MySQL introspector backed by the `prax-mysql` engine's raw query API.
749    pub struct MysqlIntrospector {
750        connection_string: String,
751    }
752
753    impl MysqlIntrospector {
754        /// Create a new MySQL introspector.
755        pub fn new(connection_string: String) -> Self {
756            Self { connection_string }
757        }
758
759        async fn engine(&self) -> CliResult<MysqlRawEngine> {
760            let pool = MysqlPool::builder()
761                .url(self.connection_string.clone())
762                .connection_timeout(std::time::Duration::from_secs(
763                    super::INTROSPECT_CONNECT_TIMEOUT_SECS,
764                ))
765                .build()
766                .await
767                .map_err(|e| CliError::Unreachable(format!("Failed to connect: {}", e)))?;
768            Ok(MysqlRawEngine::new(pool))
769        }
770
771        /// Run introspection SQL, returning each row as a JSON object.
772        /// Delegates to the shared [`super::JsonRowSource`] shim.
773        async fn rows(engine: &MysqlRawEngine, sql: &str) -> CliResult<Vec<serde_json::Value>> {
774            super::JsonRowSource::json_rows(engine, sql).await
775        }
776    }
777
778    impl Introspector for MysqlIntrospector {
779        async fn introspect(&self, options: &IntrospectionOptions) -> CliResult<DatabaseSchema> {
780            let engine = self.engine().await?;
781            // MySQL has no schema namespace distinct from the database; the
782            // connection's default database scopes information_schema queries.
783            let schema = options.schema.clone();
784            let schema_ref = schema.as_deref();
785
786            let mut db_schema = DatabaseSchema {
787                name: "database".to_string(),
788                schema: schema.clone(),
789                ..Default::default()
790            };
791
792            let table_rows = Self::rows(
793                &engine,
794                &queries::tables_query(DatabaseType::MySQL, schema_ref),
795            )
796            .await?;
797            for row in &table_rows {
798                let Some(table_name) = json_str(row, "table_name") else {
799                    continue;
800                };
801                if let Some(ref pattern) = options.table_filter
802                    && !matches_pattern(&table_name, pattern)
803                {
804                    continue;
805                }
806                if let Some(ref exclude) = options.exclude_pattern
807                    && matches_pattern(&table_name, exclude)
808                {
809                    continue;
810                }
811
812                db_schema.tables.push(TableInfo {
813                    name: table_name,
814                    schema: schema.clone(),
815                    comment: if options.include_comments {
816                        json_str(row, "comment").filter(|c| !c.is_empty())
817                    } else {
818                        None
819                    },
820                    ..Default::default()
821                });
822            }
823
824            // Tracks every enum name reserved so far, keyed by its final
825            // PascalCase form, across the *entire* run — not just earlier
826            // tables. Two enum columns in the SAME table can collide too
827            // (e.g. `a_b` and `a__b` both PascalCase to `AB`, since
828            // `pascal_case` splits on `_` and an empty segment contributes
829            // nothing), and this needs to catch that just as much as a
830            // cross-table collision.
831            let mut used_enum_names: HashSet<String> = HashSet::new();
832            for table in &mut db_schema.tables {
833                let table_enums =
834                    populate_table(&engine, table, schema_ref, options, &mut used_enum_names)
835                        .await?;
836                db_schema.enums.extend(table_enums);
837            }
838
839            Ok(db_schema)
840        }
841    }
842
843    /// Reserve a synthesized enum name, disambiguating with a numeric suffix
844    /// on collision. Comparisons are keyed by the PascalCase form — the name
845    /// each enum is actually declared under (`generate_enum`/`build_enum`
846    /// both PascalCase it) — so two distinct raw names that only collide
847    /// after that transform are still caught (e.g. table `order_item`
848    /// column `status` and table `order` column `item_status` both
849    /// synthesize `order_item_status`, since `_` is legal in both
850    /// identifiers). `used_pascal_names` is a single set shared across the
851    /// whole introspection run, so each check is an O(1) hash lookup rather
852    /// than an O(n) scan that grows with every enum column seen so far.
853    fn reserve_unique_enum_name(
854        base_name: String,
855        used_pascal_names: &mut HashSet<String>,
856    ) -> String {
857        use prax_query::introspection::{disambiguate, pascal_case};
858        disambiguate(&base_name, pascal_case, used_pascal_names)
859    }
860
861    /// Synthesize an enum name from a table/column pair, reserving it
862    /// against collisions. MySQL table/column identifiers can legally
863    /// contain characters the `.prax` grammar doesn't (a backtick-quoted
864    /// name with spaces, hyphens, etc.), so each component is sanitized
865    /// before joining — otherwise `generate_enum`/`build_enum` would
866    /// PascalCase an already-illegal string into more illegal output.
867    fn synthesize_enum_name(
868        table_name: &str,
869        column_name: &str,
870        used_enum_names: &mut HashSet<String>,
871    ) -> String {
872        use prax_query::introspection::sanitize_identifier;
873        reserve_unique_enum_name(
874            format!(
875                "{}_{}",
876                sanitize_identifier(table_name),
877                sanitize_identifier(column_name)
878            ),
879            used_enum_names,
880        )
881    }
882
883    /// Fill a table's columns, primary key, foreign keys, and indexes.
884    /// Returns any enum types discovered on this table's columns (MySQL has
885    /// no named enum catalog — each `enum(...)` column gets a synthesized
886    /// `<table>_<column>` enum name, disambiguated against
887    /// `used_enum_names`).
888    async fn populate_table(
889        engine: &MysqlRawEngine,
890        table: &mut TableInfo,
891        schema: Option<&str>,
892        options: &IntrospectionOptions,
893        used_enum_names: &mut HashSet<String>,
894    ) -> CliResult<Vec<EnumInfo>> {
895        // Columns
896        let col_rows = MysqlIntrospector::rows(
897            engine,
898            &queries::columns_query(DatabaseType::MySQL, &table.name, schema),
899        )
900        .await?;
901        let mut enums = Vec::new();
902        for row in &col_rows {
903            let Some(name) = json_str(row, "column_name") else {
904                continue;
905            };
906            let data_type = json_str(row, "data_type").unwrap_or_default();
907            let max_length = json_i32(row, "character_maximum_length");
908            let precision = json_i32(row, "numeric_precision");
909            let scale = json_i32(row, "numeric_scale");
910            let normalized = if data_type.eq_ignore_ascii_case("enum") {
911                // `udt_name` carries MySQL's raw `COLUMN_TYPE`, e.g.
912                // `enum('active','inactive')` — the only place the values
913                // are available.
914                let column_type = json_str(row, "udt_name").unwrap_or_default();
915                let values = prax_query::introspection::parse_mysql_enum_values(&column_type);
916                if values.is_empty() {
917                    // Malformed/unexpected COLUMN_TYPE (e.g. from a
918                    // MySQL-compatible proxy). Fall back to `Unknown` rather
919                    // than aborting the whole introspection run over one
920                    // column — `SchemaBuilder::build_field` already treats
921                    // an unresolvable type as skip-this-column-with-warning
922                    // (not a hard failure), the same graceful-degradation
923                    // path any other unrecognized SQL type takes.
924                    NormalizedType::Unknown(column_type)
925                } else {
926                    let enum_name = synthesize_enum_name(&table.name, &name, used_enum_names);
927                    enums.push(EnumInfo {
928                        name: enum_name.clone(),
929                        schema: schema.map(str::to_string),
930                        values,
931                    });
932                    NormalizedType::Enum(enum_name)
933                }
934            } else {
935                normalize_type(
936                    DatabaseType::MySQL,
937                    &data_type,
938                    max_length,
939                    precision,
940                    scale,
941                )
942            };
943
944            table.columns.push(ColumnInfo {
945                name,
946                db_type: data_type,
947                normalized_type: normalized,
948                nullable: json_bool(row, "nullable"),
949                default: json_str(row, "column_default"),
950                auto_increment: json_bool(row, "auto_increment"),
951                max_length,
952                precision,
953                scale,
954                comment: if options.include_comments {
955                    json_str(row, "comment").filter(|c| !c.is_empty())
956                } else {
957                    None
958                },
959                ..Default::default()
960            });
961        }
962
963        // Primary key
964        let pk_rows = MysqlIntrospector::rows(
965            engine,
966            &queries::primary_keys_query(DatabaseType::MySQL, &table.name, schema),
967        )
968        .await?;
969        for row in &pk_rows {
970            if let Some(col) = json_str(row, "column_name") {
971                table.primary_key.push(col.clone());
972                if let Some(c) = table.columns.iter_mut().find(|c| c.name == col) {
973                    c.is_primary_key = true;
974                }
975            }
976        }
977
978        // Foreign keys (grouped by constraint name, columns in order)
979        let fk_rows = MysqlIntrospector::rows(
980            engine,
981            &queries::foreign_keys_query(DatabaseType::MySQL, &table.name, schema),
982        )
983        .await?;
984        let mut fk_map: std::collections::HashMap<String, ForeignKeyInfo> =
985            std::collections::HashMap::new();
986        let mut fk_order: Vec<String> = Vec::new();
987        for row in &fk_rows {
988            let Some(cname) = json_str(row, "constraint_name") else {
989                continue;
990            };
991            let fk = fk_map.entry(cname.clone()).or_insert_with(|| {
992                fk_order.push(cname.clone());
993                ForeignKeyInfo {
994                    name: cname.clone(),
995                    columns: Vec::new(),
996                    referenced_table: json_str(row, "referenced_table").unwrap_or_default(),
997                    referenced_schema: json_str(row, "referenced_schema"),
998                    referenced_columns: Vec::new(),
999                    on_delete: ReferentialAction::from_str(
1000                        &json_str(row, "delete_rule").unwrap_or_default(),
1001                    ),
1002                    on_update: ReferentialAction::from_str(
1003                        &json_str(row, "update_rule").unwrap_or_default(),
1004                    ),
1005                }
1006            });
1007            if let Some(col) = json_str(row, "column_name") {
1008                fk.columns.push(col);
1009            }
1010            if let Some(rc) = json_str(row, "referenced_column") {
1011                fk.referenced_columns.push(rc);
1012            }
1013        }
1014        table.foreign_keys = fk_order
1015            .into_iter()
1016            .filter_map(|n| fk_map.remove(&n))
1017            .collect();
1018
1019        // Indexes (grouped by name, columns in order)
1020        let idx_rows = MysqlIntrospector::rows(
1021            engine,
1022            &queries::indexes_query(DatabaseType::MySQL, &table.name, schema),
1023        )
1024        .await?;
1025        let mut idx_map: std::collections::HashMap<String, IndexInfo> =
1026            std::collections::HashMap::new();
1027        let mut idx_order: Vec<String> = Vec::new();
1028        for row in &idx_rows {
1029            let Some(iname) = json_str(row, "index_name") else {
1030                continue;
1031            };
1032            let idx = idx_map.entry(iname.clone()).or_insert_with(|| {
1033                idx_order.push(iname.clone());
1034                IndexInfo {
1035                    name: iname.clone(),
1036                    columns: Vec::new(),
1037                    is_unique: json_bool(row, "is_unique"),
1038                    is_primary: json_bool(row, "is_primary"),
1039                    index_type: json_str(row, "index_type"),
1040                    filter: json_str(row, "filter"),
1041                }
1042            });
1043            if let Some(col) = json_str(row, "column_name") {
1044                idx.columns.push(IndexColumn {
1045                    name: col,
1046                    order: SortOrder::Asc,
1047                    ..Default::default()
1048                });
1049            }
1050        }
1051        // MySQL auto-creates a non-unique index for every FK column set;
1052        // drop it here so the diff source doesn't carry an index the schema
1053        // never declared (which would otherwise churn a DROP INDEX on every
1054        // `migrate dev`/`diff` run).
1055        //
1056        // The filter only drops an index *named after its FK constraint*
1057        // (see `is_fk_backing_index`): that is how MySQL names the implicit
1058        // index when the constraint has a symbol, which is always the case
1059        // for Prax-managed databases. A same-column index under a different
1060        // name is a real user-declared `@@index` MySQL reused, and is kept.
1061        //
1062        // Known limitation: `information_schema.statistics` doesn't record
1063        // whether an index was auto-created. An implicit index on an
1064        // *unnamed* constraint is column-named rather than
1065        // constraint-named, so it survives this filter and still churns —
1066        // the same class of inherent reverse-engineering limitation
1067        // documented in `schema_from_db.rs`'s module doc for field names
1068        // and FK constraint names. Name the constraint (or the index) to
1069        // silence it.
1070        table.indexes = idx_order
1071            .into_iter()
1072            .filter_map(|n| idx_map.remove(&n))
1073            .filter(|idx| !is_fk_backing_index(idx, &table.foreign_keys))
1074            .collect();
1075
1076        Ok(enums)
1077    }
1078
1079    /// Whether `idx` is MySQL's implicit FK-backing index for one of
1080    /// `foreign_keys`: non-unique, non-primary, covering exactly that FK's
1081    /// columns *and named after the FK constraint*.
1082    ///
1083    /// MySQL auto-creates such an index when no usable one exists, naming it
1084    /// after the constraint (the `CONSTRAINT` symbol when defined, else the
1085    /// `FOREIGN KEY index_name`, else the referencing column). The name check
1086    /// is what keeps a real user-declared `@@index` MySQL happened to reuse
1087    /// for the FK (same columns, different name) out of the filter —
1088    /// `information_schema.statistics` carries no auto-created flag, so the
1089    /// name is the only signal.
1090    ///
1091    /// Residual gaps, inherent to reverse-engineering: an implicit index on
1092    /// an *unnamed* constraint is column-named (`tbl_ibfk_N` constraint vs
1093    /// `<column>` index), so it is kept and still churns; a user index
1094    /// explicitly named exactly like its FK constraint is dropped and shows
1095    /// up as a one-time spurious "add index". The former only affects
1096    /// databases created outside Prax — Prax DDL always names its
1097    /// `CONSTRAINT`s, so its implicit indexes match by name.
1098    fn is_fk_backing_index(idx: &IndexInfo, foreign_keys: &[ForeignKeyInfo]) -> bool {
1099        if idx.is_unique || idx.is_primary {
1100            return false;
1101        }
1102        foreign_keys.iter().any(|fk| {
1103            fk.name == idx.name
1104                && fk
1105                    .columns
1106                    .iter()
1107                    .map(String::as_str)
1108                    .eq(idx.columns.iter().map(|c| c.name.as_str()))
1109        })
1110    }
1111
1112    #[cfg(test)]
1113    mod tests {
1114        use super::*;
1115
1116        fn fk(name: &str, columns: &[&str]) -> ForeignKeyInfo {
1117            ForeignKeyInfo {
1118                name: name.to_string(),
1119                columns: columns.iter().map(|c| c.to_string()).collect(),
1120                ..Default::default()
1121            }
1122        }
1123
1124        fn index(name: &str, columns: &[&str], is_unique: bool, is_primary: bool) -> IndexInfo {
1125            IndexInfo {
1126                name: name.to_string(),
1127                columns: columns
1128                    .iter()
1129                    .map(|c| IndexColumn {
1130                        name: c.to_string(),
1131                        ..Default::default()
1132                    })
1133                    .collect(),
1134                is_unique,
1135                is_primary,
1136                ..Default::default()
1137            }
1138        }
1139
1140        #[test]
1141        fn drops_non_unique_index_matching_fk_columns() {
1142            // MySQL names an auto-created FK-backing index after the FK
1143            // constraint itself, so a name match is the implicit signal.
1144            let fks = vec![fk("fk_posts_author_id", &["author_id"])];
1145            assert!(is_fk_backing_index(
1146                &index("fk_posts_author_id", &["author_id"], false, false),
1147                &fks
1148            ));
1149        }
1150
1151        #[test]
1152        fn keeps_unique_index_even_if_it_matches_fk_columns() {
1153            let fks = vec![fk("fk_posts_author_id", &["author_id"])];
1154            assert!(!is_fk_backing_index(
1155                &index("fk_posts_author_id", &["author_id"], true, false),
1156                &fks
1157            ));
1158        }
1159
1160        #[test]
1161        fn keeps_index_whose_columns_dont_match_any_fk() {
1162            let fks = vec![fk("fk_posts_author_id", &["author_id"])];
1163            assert!(!is_fk_backing_index(
1164                &index("title_idx", &["title"], false, false),
1165                &fks
1166            ));
1167        }
1168
1169        #[test]
1170        fn keeps_user_declared_index_covering_fk_columns() {
1171            // `information_schema.statistics` carries no auto-created flag,
1172            // but MySQL names an implicit FK index after the FK constraint —
1173            // so an index covering FK columns under a *different* name is a
1174            // real user-declared `@@index` MySQL reused, and must be kept.
1175            let fks = vec![fk("fk_posts_author_id", &["author_id"])];
1176            assert!(!is_fk_backing_index(
1177                &index("posts_author_id_idx", &["author_id"], false, false),
1178                &fks
1179            ));
1180        }
1181
1182        #[test]
1183        fn drops_non_unique_index_matching_composite_fk_columns_in_order() {
1184            let fks = vec![fk("fk_membership_ids", &["tenant_id", "user_id"])];
1185            assert!(is_fk_backing_index(
1186                &index("fk_membership_ids", &["tenant_id", "user_id"], false, false),
1187                &fks
1188            ));
1189        }
1190
1191        #[test]
1192        fn keeps_index_whose_composite_columns_match_a_different_order() {
1193            let fks = vec![fk("fk_membership_ids", &["tenant_id", "user_id"])];
1194            assert!(!is_fk_backing_index(
1195                &index("fk_membership_ids", &["user_id", "tenant_id"], false, false),
1196                &fks
1197            ));
1198        }
1199
1200        #[test]
1201        fn keeps_composite_user_index_covering_fk_columns() {
1202            let fks = vec![fk("fk_membership_ids", &["tenant_id", "user_id"])];
1203            assert!(!is_fk_backing_index(
1204                &index(
1205                    "membership_tenant_user_idx",
1206                    &["tenant_id", "user_id"],
1207                    false,
1208                    false
1209                ),
1210                &fks
1211            ));
1212        }
1213
1214        #[test]
1215        fn reserve_unique_enum_name_disambiguates_on_collision() {
1216            let mut used = HashSet::new();
1217            assert_eq!(
1218                reserve_unique_enum_name("order_item_status".to_string(), &mut used),
1219                "order_item_status"
1220            );
1221            assert_eq!(
1222                reserve_unique_enum_name("order_item_status".to_string(), &mut used),
1223                "order_item_status_2"
1224            );
1225            assert_eq!(
1226                reserve_unique_enum_name("order_status".to_string(), &mut used),
1227                "order_status"
1228            );
1229        }
1230
1231        #[test]
1232        fn reserve_unique_enum_name_catches_collision_that_only_appears_after_pascal_case() {
1233            // "FooBar" and "foo_bar" are different raw strings but both
1234            // PascalCase to "FooBar" — the actual name each enum is
1235            // declared under (see `generate_enum`/`build_enum`).
1236            let mut used = HashSet::new();
1237            assert_eq!(
1238                reserve_unique_enum_name("FooBar".to_string(), &mut used),
1239                "FooBar"
1240            );
1241            assert_eq!(
1242                reserve_unique_enum_name("foo_bar".to_string(), &mut used),
1243                "foo_bar_2"
1244            );
1245        }
1246
1247        #[test]
1248        fn reserve_unique_enum_name_catches_collision_within_the_same_table() {
1249            // "a_b" and "a__b" are different raw strings but both PascalCase
1250            // to "AB" (`pascal_case` splits on `_`; the empty segment from
1251            // the double underscore contributes nothing) — two enum columns
1252            // on the same table must not both reserve "AB".
1253            let mut used = HashSet::new();
1254            assert_eq!(
1255                reserve_unique_enum_name("a_b".to_string(), &mut used),
1256                "a_b"
1257            );
1258            assert_eq!(
1259                reserve_unique_enum_name("a__b".to_string(), &mut used),
1260                "a__b_2"
1261            );
1262        }
1263
1264        #[test]
1265        fn synthesize_enum_name_sanitizes_illegal_characters() {
1266            let mut used = HashSet::new();
1267            assert_eq!(
1268                synthesize_enum_name("1099-forms", "my col", &mut used),
1269                "V1099_forms_my_col"
1270            );
1271        }
1272    }
1273}
1274
1275// ============================================================================
1276// SQLite Introspector
1277// ============================================================================
1278
1279#[cfg(feature = "sqlite")]
1280pub mod sqlite {
1281    use super::*;
1282    use prax_sqlite::{SqlitePool, SqliteRawEngine};
1283
1284    /// SQLite introspector backed by the `prax-sqlite` engine's raw query API.
1285    ///
1286    /// SQLite exposes structure through PRAGMAs rather than an
1287    /// `information_schema`, so this introspector parses PRAGMA output shapes
1288    /// (`table_info`, `foreign_key_list`, `index_list`/`index_info`) directly.
1289    pub struct SqliteIntrospector {
1290        connection_string: String,
1291    }
1292
1293    impl SqliteIntrospector {
1294        /// Create a new SQLite introspector.
1295        pub fn new(connection_string: String) -> Self {
1296            Self { connection_string }
1297        }
1298
1299        async fn engine(&self) -> CliResult<SqliteRawEngine> {
1300            let pool = SqlitePool::builder()
1301                .url(self.connection_string.clone())
1302                .connection_timeout(std::time::Duration::from_secs(
1303                    super::INTROSPECT_CONNECT_TIMEOUT_SECS,
1304                ))
1305                .build()
1306                .await
1307                .map_err(|e| CliError::Unreachable(format!("Failed to open database: {}", e)))?;
1308            Ok(SqliteRawEngine::new(pool))
1309        }
1310
1311        async fn rows(engine: &SqliteRawEngine, sql: &str) -> CliResult<Vec<serde_json::Value>> {
1312            super::JsonRowSource::json_rows(engine, sql).await
1313        }
1314    }
1315
1316    impl Introspector for SqliteIntrospector {
1317        async fn introspect(&self, options: &IntrospectionOptions) -> CliResult<DatabaseSchema> {
1318            let engine = self.engine().await?;
1319
1320            let mut db_schema = DatabaseSchema {
1321                name: "database".to_string(),
1322                schema: None,
1323                ..Default::default()
1324            };
1325
1326            // SQLite has no schema namespace; the tables_query ignores it.
1327            let table_rows =
1328                Self::rows(&engine, &queries::tables_query(DatabaseType::SQLite, None)).await?;
1329            for row in &table_rows {
1330                let Some(table_name) = json_str(row, "table_name") else {
1331                    continue;
1332                };
1333                if let Some(ref pattern) = options.table_filter
1334                    && !matches_pattern(&table_name, pattern)
1335                {
1336                    continue;
1337                }
1338                if let Some(ref exclude) = options.exclude_pattern
1339                    && matches_pattern(&table_name, exclude)
1340                {
1341                    continue;
1342                }
1343                db_schema.tables.push(TableInfo {
1344                    name: table_name,
1345                    ..Default::default()
1346                });
1347            }
1348
1349            for table in &mut db_schema.tables {
1350                populate_table(&engine, table).await?;
1351            }
1352
1353            Ok(db_schema)
1354        }
1355    }
1356
1357    async fn populate_table(engine: &SqliteRawEngine, table: &mut TableInfo) -> CliResult<()> {
1358        // PRAGMA table_info: cid, name, type, notnull, dflt_value, pk
1359        // `pk` is the 1-based ordinal within the primary key (0 = not part).
1360        let col_rows = SqliteIntrospector::rows(
1361            engine,
1362            &queries::columns_query(DatabaseType::SQLite, &table.name, None),
1363        )
1364        .await?;
1365        let mut pk_positions: Vec<(i32, String)> = Vec::new();
1366        for row in &col_rows {
1367            let Some(name) = json_str(row, "name") else {
1368                continue;
1369            };
1370            let decl_type = json_str(row, "type").unwrap_or_default();
1371            // SQLite types can carry a size, e.g. VARCHAR(255); normalize on
1372            // the affinity keyword (leading identifier chars).
1373            let base_type: String = decl_type
1374                .split([' ', '('])
1375                .next()
1376                .unwrap_or("")
1377                .to_ascii_lowercase();
1378            let normalized = normalize_type(DatabaseType::SQLite, &base_type, None, None, None);
1379            let not_null = json_i32(row, "notnull").unwrap_or(0) != 0;
1380            let pk_pos = json_i32(row, "pk").unwrap_or(0);
1381            if pk_pos > 0 {
1382                pk_positions.push((pk_pos, name.clone()));
1383            }
1384
1385            table.columns.push(ColumnInfo {
1386                name,
1387                db_type: decl_type,
1388                normalized_type: normalized,
1389                // Nullability comes solely from the column's NOT NULL flag;
1390                // PK membership is represented separately via is_primary_key
1391                // (→ @id), so a composite PK with a nullable member is not
1392                // silently forced non-null.
1393                nullable: !not_null,
1394                default: json_str(row, "dflt_value"),
1395                // rowid INTEGER PRIMARY KEY columns auto-increment; detected
1396                // below once the PK is known.
1397                auto_increment: false,
1398                is_primary_key: pk_pos > 0,
1399                ..Default::default()
1400            });
1401        }
1402
1403        // Primary key columns in PK order.
1404        pk_positions.sort_by_key(|(pos, _)| *pos);
1405        table.primary_key = pk_positions.into_iter().map(|(_, name)| name).collect();
1406
1407        // A single INTEGER PRIMARY KEY is an alias for rowid (auto-increment).
1408        if table.primary_key.len() == 1
1409            && let Some(col) = table
1410                .columns
1411                .iter_mut()
1412                .find(|c| c.name == table.primary_key[0])
1413            && col.db_type.to_ascii_lowercase().contains("int")
1414        {
1415            col.auto_increment = true;
1416        }
1417
1418        // PRAGMA foreign_key_list: id, seq, table, from, to, on_update, on_delete, match
1419        // Rows for one FK share `id`; `seq` orders the columns.
1420        let fk_rows = SqliteIntrospector::rows(
1421            engine,
1422            &queries::foreign_keys_query(DatabaseType::SQLite, &table.name, None),
1423        )
1424        .await?;
1425        let mut fk_map: std::collections::BTreeMap<i64, ForeignKeyInfo> =
1426            std::collections::BTreeMap::new();
1427        for row in &fk_rows {
1428            let id = row.get("id").and_then(|v| v.as_i64()).unwrap_or(0);
1429            let fk = fk_map.entry(id).or_insert_with(|| ForeignKeyInfo {
1430                // SQLite FKs are unnamed; synthesize a stable name so the
1431                // diff engine can match them. The .prax must pin this via
1432                // @relation(map: "fk_<table>_<col>") to round-trip cleanly.
1433                name: String::new(),
1434                columns: Vec::new(),
1435                referenced_table: json_str(row, "table").unwrap_or_default(),
1436                referenced_schema: None,
1437                referenced_columns: Vec::new(),
1438                on_delete: ReferentialAction::from_str(
1439                    &json_str(row, "on_delete").unwrap_or_default(),
1440                ),
1441                on_update: ReferentialAction::from_str(
1442                    &json_str(row, "on_update").unwrap_or_default(),
1443                ),
1444            });
1445            if let Some(from) = json_str(row, "from") {
1446                fk.columns.push(from);
1447            }
1448            if let Some(to) = json_str(row, "to") {
1449                fk.referenced_columns.push(to);
1450            }
1451        }
1452        table.foreign_keys = fk_map
1453            .into_values()
1454            .map(|mut fk| {
1455                if fk.name.is_empty() {
1456                    fk.name = format!("fk_{}_{}", table.name, fk.columns.join("_"));
1457                }
1458                fk
1459            })
1460            .collect();
1461
1462        // PRAGMA index_list: seq, name, unique, origin, partial
1463        // origin 'pk' is the implicit PK index; skip it (already represented).
1464        let idx_rows = SqliteIntrospector::rows(
1465            engine,
1466            &queries::indexes_query(DatabaseType::SQLite, &table.name, None),
1467        )
1468        .await?;
1469        for row in &idx_rows {
1470            let Some(idx_name) = json_str(row, "name") else {
1471                continue;
1472            };
1473            let origin = json_str(row, "origin").unwrap_or_default();
1474            let is_primary = origin == "pk";
1475            let is_unique = json_i32(row, "unique").unwrap_or(0) != 0;
1476
1477            // PRAGMA index_info(name): seqno, cid, name — the indexed columns.
1478            let info_rows = SqliteIntrospector::rows(
1479                engine,
1480                &format!("PRAGMA index_info('{}')", idx_name.replace('\'', "''")),
1481            )
1482            .await?;
1483            let columns: Vec<IndexColumn> = info_rows
1484                .iter()
1485                .filter_map(|r| json_str(r, "name"))
1486                .map(|name| IndexColumn {
1487                    name,
1488                    order: SortOrder::Asc,
1489                    ..Default::default()
1490                })
1491                .collect();
1492
1493            table.indexes.push(IndexInfo {
1494                name: idx_name,
1495                columns,
1496                is_unique,
1497                is_primary,
1498                index_type: None,
1499                filter: None,
1500            });
1501        }
1502
1503        Ok(())
1504    }
1505}
1506
1507// ============================================================================
1508// MSSQL Introspector
1509// ============================================================================
1510
1511#[cfg(feature = "mssql")]
1512pub mod mssql {
1513    use super::*;
1514    use prax_mssql::MssqlPool;
1515    use prax_mssql::Row;
1516
1517    /// MSSQL introspector backed by the `prax-mssql` engine's pooled
1518    /// connection. Reads typed `tiberius::Row` columns from the `sys.*`
1519    /// catalog queries.
1520    pub struct MssqlIntrospector {
1521        connection_string: String,
1522    }
1523
1524    impl MssqlIntrospector {
1525        /// Create a new MSSQL introspector.
1526        pub fn new(connection_string: String) -> Self {
1527            Self { connection_string }
1528        }
1529    }
1530
1531    /// Read a nullable string column by name.
1532    fn row_str(row: &Row, col: &str) -> Option<String> {
1533        row.try_get::<&str, _>(col)
1534            .ok()
1535            .flatten()
1536            .map(str::to_string)
1537    }
1538
1539    /// Read a bit/int column as a bool.
1540    fn row_bool(row: &Row, col: &str) -> bool {
1541        if let Ok(Some(b)) = row.try_get::<bool, _>(col) {
1542            return b;
1543        }
1544        // Some bit-like columns arrive as integers.
1545        row_i64(row, col).is_some_and(|i| i != 0)
1546    }
1547
1548    /// Read an integer column, tolerating i16/i32/i64/u8 widths.
1549    fn row_i64(row: &Row, col: &str) -> Option<i64> {
1550        if let Ok(Some(v)) = row.try_get::<i32, _>(col) {
1551            return Some(v as i64);
1552        }
1553        if let Ok(Some(v)) = row.try_get::<i64, _>(col) {
1554            return Some(v);
1555        }
1556        if let Ok(Some(v)) = row.try_get::<i16, _>(col) {
1557            return Some(v as i64);
1558        }
1559        if let Ok(Some(v)) = row.try_get::<u8, _>(col) {
1560            return Some(v as i64);
1561        }
1562        None
1563    }
1564
1565    fn row_i32(row: &Row, col: &str) -> Option<i32> {
1566        row_i64(row, col).and_then(|v| i32::try_from(v).ok())
1567    }
1568
1569    impl Introspector for MssqlIntrospector {
1570        async fn introspect(&self, options: &IntrospectionOptions) -> CliResult<DatabaseSchema> {
1571            let pool = MssqlPool::builder()
1572                .connection_string(self.connection_string.clone())
1573                .connection_timeout(std::time::Duration::from_secs(
1574                    super::INTROSPECT_CONNECT_TIMEOUT_SECS,
1575                ))
1576                .build()
1577                .await
1578                .map_err(|e| CliError::Unreachable(format!("Failed to connect: {}", e)))?;
1579            let mut conn = pool.get().await.map_err(|e| {
1580                CliError::Unreachable(format!("Failed to acquire connection: {}", e))
1581            })?;
1582
1583            let schema_name = options.schema.clone().unwrap_or_else(|| "dbo".to_string());
1584            let schema_ref = Some(schema_name.as_str());
1585
1586            let mut db_schema = DatabaseSchema {
1587                name: "database".to_string(),
1588                schema: Some(schema_name.clone()),
1589                ..Default::default()
1590            };
1591
1592            let table_rows = conn
1593                .query(&queries::tables_query(DatabaseType::MSSQL, schema_ref), &[])
1594                .await
1595                .map_err(|e| CliError::Database(format!("Failed to query tables: {}", e)))?;
1596            for row in &table_rows {
1597                let Some(table_name) = row_str(row, "table_name") else {
1598                    continue;
1599                };
1600                if let Some(ref pattern) = options.table_filter
1601                    && !matches_pattern(&table_name, pattern)
1602                {
1603                    continue;
1604                }
1605                if let Some(ref exclude) = options.exclude_pattern
1606                    && matches_pattern(&table_name, exclude)
1607                {
1608                    continue;
1609                }
1610                db_schema.tables.push(TableInfo {
1611                    name: table_name,
1612                    schema: Some(schema_name.clone()),
1613                    comment: if options.include_comments {
1614                        row_str(row, "comment")
1615                    } else {
1616                        None
1617                    },
1618                    ..Default::default()
1619                });
1620            }
1621
1622            for i in 0..db_schema.tables.len() {
1623                let table_name = db_schema.tables[i].name.clone();
1624
1625                // Columns
1626                let col_rows = conn
1627                    .query(
1628                        &queries::columns_query(DatabaseType::MSSQL, &table_name, schema_ref),
1629                        &[],
1630                    )
1631                    .await
1632                    .map_err(|e| CliError::Database(format!("Failed to query columns: {}", e)))?;
1633                for row in &col_rows {
1634                    let Some(name) = row_str(row, "column_name") else {
1635                        continue;
1636                    };
1637                    let data_type = row_str(row, "data_type").unwrap_or_default();
1638                    // NOTE: sys.columns.max_length is a BYTE length, not a
1639                    // character count — nvarchar(255) reports 510 and MAX
1640                    // reports -1. Harmless today because VarChar/Char normalize
1641                    // to TEXT (length discarded) in the diff-source mapping; if
1642                    // length-sensitive types are added, halve for n-types and
1643                    // special-case -1 → MAX before comparing.
1644                    let max_length = row_i32(row, "character_maximum_length");
1645                    let precision = row_i32(row, "numeric_precision");
1646                    let scale = row_i32(row, "numeric_scale");
1647                    let normalized = normalize_type(
1648                        DatabaseType::MSSQL,
1649                        &data_type,
1650                        max_length,
1651                        precision,
1652                        scale,
1653                    );
1654                    db_schema.tables[i].columns.push(ColumnInfo {
1655                        name,
1656                        db_type: data_type,
1657                        normalized_type: normalized,
1658                        nullable: row_bool(row, "nullable"),
1659                        default: row_str(row, "column_default"),
1660                        auto_increment: row_bool(row, "auto_increment"),
1661                        max_length,
1662                        precision,
1663                        scale,
1664                        comment: if options.include_comments {
1665                            row_str(row, "comment")
1666                        } else {
1667                            None
1668                        },
1669                        ..Default::default()
1670                    });
1671                }
1672
1673                // Primary key
1674                let pk_rows = conn
1675                    .query(
1676                        &queries::primary_keys_query(DatabaseType::MSSQL, &table_name, schema_ref),
1677                        &[],
1678                    )
1679                    .await
1680                    .map_err(|e| {
1681                        CliError::Database(format!("Failed to query primary keys: {}", e))
1682                    })?;
1683                for row in &pk_rows {
1684                    if let Some(col) = row_str(row, "column_name") {
1685                        db_schema.tables[i].primary_key.push(col.clone());
1686                        if let Some(c) = db_schema.tables[i]
1687                            .columns
1688                            .iter_mut()
1689                            .find(|c| c.name == col)
1690                        {
1691                            c.is_primary_key = true;
1692                        }
1693                    }
1694                }
1695
1696                // Foreign keys
1697                let fk_rows = conn
1698                    .query(
1699                        &queries::foreign_keys_query(DatabaseType::MSSQL, &table_name, schema_ref),
1700                        &[],
1701                    )
1702                    .await
1703                    .map_err(|e| {
1704                        CliError::Database(format!("Failed to query foreign keys: {}", e))
1705                    })?;
1706                let mut fk_map: std::collections::HashMap<String, ForeignKeyInfo> =
1707                    std::collections::HashMap::new();
1708                let mut fk_order: Vec<String> = Vec::new();
1709                for row in &fk_rows {
1710                    let Some(cname) = row_str(row, "constraint_name") else {
1711                        continue;
1712                    };
1713                    let fk = fk_map.entry(cname.clone()).or_insert_with(|| {
1714                        fk_order.push(cname.clone());
1715                        ForeignKeyInfo {
1716                            name: cname.clone(),
1717                            columns: Vec::new(),
1718                            referenced_table: row_str(row, "referenced_table").unwrap_or_default(),
1719                            referenced_schema: row_str(row, "referenced_schema"),
1720                            referenced_columns: Vec::new(),
1721                            on_delete: ReferentialAction::from_str(
1722                                &row_str(row, "delete_rule").unwrap_or_default(),
1723                            ),
1724                            on_update: ReferentialAction::from_str(
1725                                &row_str(row, "update_rule").unwrap_or_default(),
1726                            ),
1727                        }
1728                    });
1729                    if let Some(col) = row_str(row, "column_name") {
1730                        fk.columns.push(col);
1731                    }
1732                    if let Some(rc) = row_str(row, "referenced_column") {
1733                        fk.referenced_columns.push(rc);
1734                    }
1735                }
1736                db_schema.tables[i].foreign_keys = fk_order
1737                    .into_iter()
1738                    .filter_map(|n| fk_map.remove(&n))
1739                    .collect();
1740
1741                // Indexes
1742                let idx_rows = conn
1743                    .query(
1744                        &queries::indexes_query(DatabaseType::MSSQL, &table_name, schema_ref),
1745                        &[],
1746                    )
1747                    .await
1748                    .map_err(|e| CliError::Database(format!("Failed to query indexes: {}", e)))?;
1749                let mut idx_map: std::collections::HashMap<String, IndexInfo> =
1750                    std::collections::HashMap::new();
1751                let mut idx_order: Vec<String> = Vec::new();
1752                for row in &idx_rows {
1753                    let Some(iname) = row_str(row, "index_name") else {
1754                        continue;
1755                    };
1756                    let idx = idx_map.entry(iname.clone()).or_insert_with(|| {
1757                        idx_order.push(iname.clone());
1758                        IndexInfo {
1759                            name: iname.clone(),
1760                            columns: Vec::new(),
1761                            is_unique: row_bool(row, "is_unique"),
1762                            is_primary: row_bool(row, "is_primary"),
1763                            index_type: row_str(row, "index_type"),
1764                            filter: row_str(row, "filter"),
1765                        }
1766                    });
1767                    if let Some(col) = row_str(row, "column_name") {
1768                        idx.columns.push(IndexColumn {
1769                            name: col,
1770                            order: SortOrder::Asc,
1771                            ..Default::default()
1772                        });
1773                    }
1774                }
1775                db_schema.tables[i].indexes = idx_order
1776                    .into_iter()
1777                    .filter_map(|n| idx_map.remove(&n))
1778                    .collect();
1779            }
1780
1781            Ok(db_schema)
1782        }
1783    }
1784}
1785
1786// ============================================================================
1787// Output Formatters
1788// ============================================================================
1789
1790/// Generate Prax schema output.
1791pub fn format_as_prax(schema: &DatabaseSchema, config: &Config) -> String {
1792    let mut output = String::new();
1793
1794    output.push_str("// Generated by `prax db pull`\n");
1795    output.push_str("// Edit this file to customize your schema\n\n");
1796
1797    output.push_str("datasource db {\n");
1798    output.push_str(&format!(
1799        "    provider = \"{}\"\n",
1800        config.database.provider
1801    ));
1802    output.push_str("    url      = env(\"DATABASE_URL\")\n");
1803    output.push_str("}\n\n");
1804
1805    output.push_str("generator client {\n");
1806    output.push_str("    provider = \"prax-client-rust\"\n");
1807    output.push_str("    output   = \"./src/generated\"\n");
1808    output.push_str("}\n\n");
1809
1810    // Use the generate_prax_schema function
1811    output.push_str(&generate_prax_schema(schema));
1812
1813    output
1814}
1815
1816/// Generate JSON output.
1817pub fn format_as_json(schema: &DatabaseSchema) -> CliResult<String> {
1818    serde_json::to_string_pretty(schema)
1819        .map_err(|e| CliError::Config(format!("Failed to serialize schema: {}", e)))
1820}
1821
1822/// Generate SQL DDL output.
1823pub fn format_as_sql(schema: &DatabaseSchema, db_type: DatabaseType) -> String {
1824    let mut output = String::new();
1825
1826    output.push_str("-- Generated by `prax db pull`\n");
1827    output.push_str(&format!("-- Database: {}\n\n", db_type_name(db_type)));
1828
1829    // Generate enums (PostgreSQL only)
1830    if db_type == DatabaseType::PostgreSQL {
1831        for enum_info in &schema.enums {
1832            output.push_str(&format!("CREATE TYPE {} AS ENUM (\n", enum_info.name));
1833            let values: Vec<String> = enum_info
1834                .values
1835                .iter()
1836                .map(|v| format!("    '{}'", v))
1837                .collect();
1838            output.push_str(&values.join(",\n"));
1839            output.push_str("\n);\n\n");
1840        }
1841    }
1842
1843    // Generate tables
1844    for table in &schema.tables {
1845        output.push_str(&format!(
1846            "CREATE TABLE {} (\n",
1847            quote_identifier(&table.name, db_type)
1848        ));
1849
1850        let mut col_defs: Vec<String> = Vec::new();
1851
1852        for col in &table.columns {
1853            let mut def = format!(
1854                "    {} {}",
1855                quote_identifier(&col.name, db_type),
1856                col.db_type
1857            );
1858
1859            if !col.nullable {
1860                def.push_str(" NOT NULL");
1861            }
1862
1863            if let Some(ref default) = col.default {
1864                def.push_str(&format!(" DEFAULT {}", default));
1865            }
1866
1867            col_defs.push(def);
1868        }
1869
1870        // Primary key
1871        if !table.primary_key.is_empty() {
1872            let pk_cols: Vec<String> = table
1873                .primary_key
1874                .iter()
1875                .map(|c| quote_identifier(c, db_type))
1876                .collect();
1877            col_defs.push(format!("    PRIMARY KEY ({})", pk_cols.join(", ")));
1878        }
1879
1880        output.push_str(&col_defs.join(",\n"));
1881        output.push_str("\n);\n\n");
1882
1883        // Indexes
1884        for idx in &table.indexes {
1885            if idx.is_primary {
1886                continue;
1887            }
1888
1889            let unique = if idx.is_unique { "UNIQUE " } else { "" };
1890            let cols: Vec<String> = idx
1891                .columns
1892                .iter()
1893                .map(|c| quote_identifier(&c.name, db_type))
1894                .collect();
1895
1896            output.push_str(&format!(
1897                "CREATE {}INDEX {} ON {} ({});\n",
1898                unique,
1899                quote_identifier(&idx.name, db_type),
1900                quote_identifier(&table.name, db_type),
1901                cols.join(", ")
1902            ));
1903        }
1904
1905        output.push('\n');
1906    }
1907
1908    output
1909}
1910
1911fn db_type_name(db_type: DatabaseType) -> &'static str {
1912    match db_type {
1913        DatabaseType::PostgreSQL => "PostgreSQL",
1914        DatabaseType::MySQL => "MySQL",
1915        DatabaseType::SQLite => "SQLite",
1916        DatabaseType::MSSQL => "SQL Server",
1917    }
1918}
1919
1920fn quote_identifier(name: &str, db_type: DatabaseType) -> String {
1921    match db_type {
1922        DatabaseType::PostgreSQL => format!("\"{}\"", name),
1923        DatabaseType::MySQL => format!("`{}`", name),
1924        DatabaseType::SQLite => format!("\"{}\"", name),
1925        DatabaseType::MSSQL => format!("[{}]", name),
1926    }
1927}