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