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 std::collections::HashMap;
7
8use prax_query::introspection::{
9    ColumnInfo, DatabaseSchema, EnumInfo, ForeignKeyInfo, IndexColumn, IndexInfo,
10    ReferentialAction, SortOrder, TableInfo, ViewInfo, generate_prax_schema, normalize_type,
11    queries,
12};
13use prax_query::sql::DatabaseType;
14
15use crate::config::Config;
16use crate::error::{CliError, CliResult};
17
18/// Introspection options.
19#[derive(Debug, Clone)]
20pub struct IntrospectionOptions {
21    /// Schema/namespace to introspect.
22    pub schema: Option<String>,
23    /// Include views.
24    pub include_views: bool,
25    /// Include materialized views.
26    pub include_materialized_views: bool,
27    /// Table filter pattern.
28    pub table_filter: Option<String>,
29    /// Tables to exclude.
30    pub exclude_pattern: Option<String>,
31    /// Include comments.
32    pub include_comments: bool,
33    /// Sample size for MongoDB.
34    pub sample_size: usize,
35}
36
37impl Default for IntrospectionOptions {
38    fn default() -> Self {
39        Self {
40            schema: None,
41            include_views: false,
42            include_materialized_views: false,
43            table_filter: None,
44            exclude_pattern: None,
45            include_comments: true,
46            sample_size: 100,
47        }
48    }
49}
50
51/// Database introspector trait.
52#[allow(async_fn_in_trait)]
53pub trait Introspector {
54    /// Introspect the database and return schema information.
55    async fn introspect(&self, options: &IntrospectionOptions) -> CliResult<DatabaseSchema>;
56}
57
58/// Get the database type from provider string.
59pub fn get_database_type(provider: &str) -> CliResult<DatabaseType> {
60    match provider.to_lowercase().as_str() {
61        "postgresql" | "postgres" | "pg" => Ok(DatabaseType::PostgreSQL),
62        "mysql" | "mariadb" => Ok(DatabaseType::MySQL),
63        "sqlite" | "sqlite3" => Ok(DatabaseType::SQLite),
64        "mssql" | "sqlserver" | "sql_server" => Ok(DatabaseType::MSSQL),
65        _ => Err(CliError::Config(format!(
66            "Unsupported database provider: {}",
67            provider
68        ))),
69    }
70}
71
72/// Get default schema for database type.
73pub fn default_schema(db_type: DatabaseType) -> &'static str {
74    match db_type {
75        DatabaseType::PostgreSQL => "public",
76        DatabaseType::MySQL => "",
77        DatabaseType::SQLite => "",
78        DatabaseType::MSSQL => "dbo",
79    }
80}
81
82// ============================================================================
83// PostgreSQL Introspector
84// ============================================================================
85
86#[cfg(feature = "postgres")]
87pub mod postgres {
88    use super::*;
89    use tokio_postgres::{Client, NoTls, Row};
90
91    /// PostgreSQL introspector.
92    pub struct PostgresIntrospector {
93        connection_string: String,
94    }
95
96    impl PostgresIntrospector {
97        /// Create a new PostgreSQL introspector.
98        pub fn new(connection_string: String) -> Self {
99            Self { connection_string }
100        }
101
102        /// Connect to the database.
103        async fn connect(&self) -> CliResult<Client> {
104            // Parse the DSN the same way tokio-postgres will, so the sslmode
105            // it carries is honored: anything but `disable` goes through the
106            // workspace's shared rustls connector (chain + hostname verified
107            // against the Mozilla root store). `prefer` still falls back to
108            // plaintext when the server declines TLS.
109            let config = self
110                .connection_string
111                .parse::<tokio_postgres::Config>()
112                .map_err(|e| CliError::Config(format!("Invalid connection string: {}", e)))?;
113
114            let tls_disabled = matches!(
115                config.get_ssl_mode(),
116                tokio_postgres::config::SslMode::Disable
117            );
118
119            if tls_disabled && !config.get_hosts().iter().all(is_local_host) {
120                crate::output::warn(
121                    "sslmode=disable with a non-local host: credentials and data will be \
122                     sent in plaintext.",
123                );
124            }
125
126            // The two connector types produce different `Connection`
127            // generics, so drive each arm independently and unify on the
128            // stream-agnostic `Client`.
129            let client = if tls_disabled {
130                let (client, connection) = tokio_postgres::connect(&self.connection_string, NoTls)
131                    .await
132                    .map_err(|e| CliError::Database(format!("Failed to connect: {}", e)))?;
133                tokio::spawn(async move {
134                    if let Err(e) = connection.await {
135                        eprintln!("Connection error: {}", e);
136                    }
137                });
138                client
139            } else {
140                let (client, connection) = tokio_postgres::connect(
141                    &self.connection_string,
142                    prax_postgres::tls::make_tls_connector(),
143                )
144                .await
145                .map_err(|e| CliError::Database(format!("Failed to connect: {}", e)))?;
146                tokio::spawn(async move {
147                    if let Err(e) = connection.await {
148                        eprintln!("Connection error: {}", e);
149                    }
150                });
151                client
152            };
153
154            Ok(client)
155        }
156    }
157
158    impl Introspector for PostgresIntrospector {
159        async fn introspect(&self, options: &IntrospectionOptions) -> CliResult<DatabaseSchema> {
160            let client = self.connect().await?;
161            let schema_name = options.schema.as_deref().unwrap_or("public");
162
163            let mut db_schema = DatabaseSchema {
164                name: "database".to_string(),
165                schema: Some(schema_name.to_string()),
166                ..Default::default()
167            };
168
169            // Get tables
170            let tables_sql = queries::tables_query(DatabaseType::PostgreSQL, Some(schema_name));
171            let table_rows = client
172                .query(&tables_sql, &[])
173                .await
174                .map_err(|e| CliError::Database(format!("Failed to query tables: {}", e)))?;
175
176            for row in table_rows {
177                let table_name: String = row.get(0);
178
179                // Apply filters
180                if let Some(ref pattern) = options.table_filter
181                    && !matches_pattern(&table_name, pattern)
182                {
183                    continue;
184                }
185                if let Some(ref exclude) = options.exclude_pattern
186                    && matches_pattern(&table_name, exclude)
187                {
188                    continue;
189                }
190
191                let comment: Option<String> = row.try_get(1).ok();
192
193                db_schema.tables.push(TableInfo {
194                    name: table_name,
195                    schema: Some(schema_name.to_string()),
196                    comment: if options.include_comments {
197                        comment
198                    } else {
199                        None
200                    },
201                    ..Default::default()
202                });
203            }
204
205            // Fetch columns, primary keys, foreign keys, and indexes for the
206            // whole schema in one query each, then group rows by table in
207            // memory: 4 round-trips total instead of 4 per table. The table
208            // name is appended as the last selected column and used as the
209            // first ORDER BY key so grouped rows keep the exact per-table
210            // ordering of the original per-table queries.
211            let cols_sql = "SELECT \
212                    c.column_name, \
213                    c.data_type, \
214                    c.udt_name, \
215                    c.is_nullable = 'YES' as nullable, \
216                    c.column_default, \
217                    c.character_maximum_length, \
218                    c.numeric_precision, \
219                    c.numeric_scale, \
220                    col_description((quote_ident(c.table_schema) || '.' || quote_ident(c.table_name))::regclass, c.ordinal_position) as comment, \
221                    CASE WHEN c.column_default LIKE 'nextval%' THEN true ELSE false END as auto_increment, \
222                    c.table_name \
223                 FROM information_schema.columns c \
224                 WHERE c.table_schema = $1 \
225                 ORDER BY c.table_name, c.ordinal_position";
226            let col_rows = client
227                .query(cols_sql, &[&schema_name])
228                .await
229                .map_err(|e| CliError::Database(format!("Failed to query columns: {}", e)))?;
230
231            let mut columns_by_table: HashMap<String, Vec<Row>> = HashMap::new();
232            for col_row in col_rows {
233                let table_name: String = col_row.get(10);
234                columns_by_table
235                    .entry(table_name)
236                    .or_default()
237                    .push(col_row);
238            }
239
240            let pk_sql = "SELECT a.attname as column_name, c.relname as table_name \
241                 FROM pg_index i \
242                 JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey) \
243                 JOIN pg_class c ON c.oid = i.indrelid \
244                 JOIN pg_namespace n ON n.oid = c.relnamespace \
245                 WHERE i.indisprimary AND n.nspname = $1 \
246                 ORDER BY c.relname, array_position(i.indkey, a.attnum)";
247            let pk_rows = client
248                .query(pk_sql, &[&schema_name])
249                .await
250                .map_err(|e| CliError::Database(format!("Failed to query primary keys: {}", e)))?;
251
252            let mut pks_by_table: HashMap<String, Vec<Row>> = HashMap::new();
253            for pk_row in pk_rows {
254                let table_name: String = pk_row.get(1);
255                pks_by_table.entry(table_name).or_default().push(pk_row);
256            }
257
258            let fk_sql = "SELECT \
259                    tc.constraint_name, \
260                    kcu.column_name, \
261                    ccu.table_name as referenced_table, \
262                    ccu.table_schema as referenced_schema, \
263                    ccu.column_name as referenced_column, \
264                    rc.delete_rule, \
265                    rc.update_rule, \
266                    tc.table_name \
267                 FROM information_schema.table_constraints tc \
268                 JOIN information_schema.key_column_usage kcu ON tc.constraint_name = kcu.constraint_name \
269                 JOIN information_schema.constraint_column_usage ccu ON ccu.constraint_name = tc.constraint_name \
270                 JOIN information_schema.referential_constraints rc ON rc.constraint_name = tc.constraint_name \
271                 WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_schema = $1 \
272                 ORDER BY tc.table_name, tc.constraint_name, kcu.ordinal_position";
273            let fk_rows = client
274                .query(fk_sql, &[&schema_name])
275                .await
276                .map_err(|e| CliError::Database(format!("Failed to query foreign keys: {}", e)))?;
277
278            let mut fks_by_table: HashMap<String, Vec<Row>> = HashMap::new();
279            for fk_row in fk_rows {
280                let table_name: String = fk_row.get(7);
281                fks_by_table.entry(table_name).or_default().push(fk_row);
282            }
283
284            let idx_sql = "SELECT \
285                    i.relname as index_name, \
286                    a.attname as column_name, \
287                    ix.indisunique as is_unique, \
288                    ix.indisprimary as is_primary, \
289                    am.amname as index_type, \
290                    pg_get_expr(ix.indpred, ix.indrelid) as filter, \
291                    t.relname as table_name \
292                 FROM pg_index ix \
293                 JOIN pg_class t ON t.oid = ix.indrelid \
294                 JOIN pg_class i ON i.oid = ix.indexrelid \
295                 JOIN pg_namespace n ON n.oid = t.relnamespace \
296                 JOIN pg_am am ON i.relam = am.oid \
297                 JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(ix.indkey) \
298                 WHERE n.nspname = $1 \
299                 ORDER BY t.relname, i.relname, array_position(ix.indkey, a.attnum)";
300            let idx_rows = client
301                .query(idx_sql, &[&schema_name])
302                .await
303                .map_err(|e| CliError::Database(format!("Failed to query indexes: {}", e)))?;
304
305            let mut indexes_by_table: HashMap<String, Vec<Row>> = HashMap::new();
306            for idx_row in idx_rows {
307                let table_name: String = idx_row.get(6);
308                indexes_by_table
309                    .entry(table_name)
310                    .or_default()
311                    .push(idx_row);
312            }
313
314            // Populate each table from the pre-fetched rows.
315            for table in &mut db_schema.tables {
316                for col_row in columns_by_table.remove(&table.name).unwrap_or_default() {
317                    let col_name: String = col_row.get(0);
318                    let data_type: String = col_row.get(1);
319                    let udt_name: String = col_row.get(2);
320                    let nullable: bool = col_row.get(3);
321                    let default: Option<String> = col_row.try_get(4).ok();
322                    let max_length: Option<i32> = col_row.try_get(5).ok();
323                    let precision: Option<i32> = col_row.try_get(6).ok();
324                    let scale: Option<i32> = col_row.try_get(7).ok();
325                    let comment: Option<String> = col_row.try_get(8).ok();
326                    let auto_increment: bool = col_row.try_get(9).unwrap_or(false);
327
328                    let normalized = normalize_type(
329                        DatabaseType::PostgreSQL,
330                        &udt_name,
331                        max_length,
332                        precision,
333                        scale,
334                    );
335
336                    table.columns.push(ColumnInfo {
337                        name: col_name,
338                        db_type: data_type,
339                        normalized_type: normalized,
340                        nullable,
341                        default,
342                        auto_increment,
343                        max_length,
344                        precision,
345                        scale,
346                        comment: if options.include_comments {
347                            comment
348                        } else {
349                            None
350                        },
351                        ..Default::default()
352                    });
353                }
354
355                for pk_row in pks_by_table.remove(&table.name).unwrap_or_default() {
356                    let col_name: String = pk_row.get(0);
357                    table.primary_key.push(col_name.clone());
358
359                    // Mark column as primary key
360                    if let Some(col) = table.columns.iter_mut().find(|c| c.name == col_name) {
361                        col.is_primary_key = true;
362                    }
363                }
364
365                let mut fk_map: HashMap<String, ForeignKeyInfo> = HashMap::new();
366                for fk_row in fks_by_table.remove(&table.name).unwrap_or_default() {
367                    let constraint_name: String = fk_row.get(0);
368                    let column_name: String = fk_row.get(1);
369                    let ref_table: String = fk_row.get(2);
370                    let ref_schema: Option<String> = fk_row.try_get(3).ok();
371                    let ref_column: String = fk_row.get(4);
372                    let delete_rule: String = fk_row.get(5);
373                    let update_rule: String = fk_row.get(6);
374
375                    let fk =
376                        fk_map
377                            .entry(constraint_name.clone())
378                            .or_insert_with(|| ForeignKeyInfo {
379                                name: constraint_name,
380                                columns: Vec::new(),
381                                referenced_table: ref_table,
382                                referenced_schema: ref_schema,
383                                referenced_columns: Vec::new(),
384                                on_delete: ReferentialAction::from_str(&delete_rule),
385                                on_update: ReferentialAction::from_str(&update_rule),
386                            });
387
388                    fk.columns.push(column_name);
389                    fk.referenced_columns.push(ref_column);
390                }
391
392                table.foreign_keys = fk_map.into_values().collect();
393
394                let mut idx_map: HashMap<String, IndexInfo> = HashMap::new();
395                for idx_row in indexes_by_table.remove(&table.name).unwrap_or_default() {
396                    let idx_name: String = idx_row.get(0);
397                    let col_name: String = idx_row.get(1);
398                    let is_unique: bool = idx_row.get(2);
399                    let is_primary: bool = idx_row.get(3);
400                    let idx_type: Option<String> = idx_row.try_get(4).ok();
401                    let filter: Option<String> = idx_row.try_get(5).ok();
402
403                    let idx = idx_map
404                        .entry(idx_name.clone())
405                        .or_insert_with(|| IndexInfo {
406                            name: idx_name,
407                            columns: Vec::new(),
408                            is_unique,
409                            is_primary,
410                            index_type: idx_type,
411                            filter,
412                        });
413
414                    idx.columns.push(IndexColumn {
415                        name: col_name,
416                        order: SortOrder::Asc,
417                        ..Default::default()
418                    });
419                }
420
421                table.indexes = idx_map.into_values().collect();
422            }
423
424            // Get enums
425            let enums_sql = queries::enums_query(Some(schema_name));
426            let enum_rows = client
427                .query(&enums_sql, &[])
428                .await
429                .map_err(|e| CliError::Database(format!("Failed to query enums: {}", e)))?;
430
431            let mut enum_map: HashMap<String, EnumInfo> = HashMap::new();
432            for enum_row in enum_rows {
433                let enum_name: String = enum_row.get(0);
434                let enum_value: String = enum_row.get(1);
435
436                let enum_info = enum_map
437                    .entry(enum_name.clone())
438                    .or_insert_with(|| EnumInfo {
439                        name: enum_name,
440                        schema: Some(schema_name.to_string()),
441                        values: Vec::new(),
442                    });
443
444                enum_info.values.push(enum_value);
445            }
446
447            db_schema.enums = enum_map.into_values().collect();
448
449            // Get views
450            if options.include_views || options.include_materialized_views {
451                let views_sql = queries::views_query(DatabaseType::PostgreSQL, Some(schema_name));
452                let view_rows = client
453                    .query(&views_sql, &[])
454                    .await
455                    .map_err(|e| CliError::Database(format!("Failed to query views: {}", e)))?;
456
457                for view_row in view_rows {
458                    let view_name: String = view_row.get(0);
459                    let definition: Option<String> = view_row.try_get(1).ok();
460                    let is_materialized: bool = view_row.get(2);
461
462                    if is_materialized && !options.include_materialized_views {
463                        continue;
464                    }
465                    if !is_materialized && !options.include_views {
466                        continue;
467                    }
468
469                    db_schema.views.push(ViewInfo {
470                        name: view_name,
471                        schema: Some(schema_name.to_string()),
472                        definition,
473                        is_materialized,
474                        columns: Vec::new(),
475                    });
476                }
477            }
478
479            Ok(db_schema)
480        }
481    }
482
483    /// Whether a parsed DSN host is local (loopback TCP or a Unix socket).
484    fn is_local_host(host: &tokio_postgres::config::Host) -> bool {
485        match host {
486            tokio_postgres::config::Host::Tcp(name) => {
487                name == "localhost" || name == "127.0.0.1" || name == "::1"
488            }
489            tokio_postgres::config::Host::Unix(_) => true,
490        }
491    }
492
493    /// Simple glob-style pattern matching.
494    fn matches_pattern(name: &str, pattern: &str) -> bool {
495        if pattern == "*" {
496            return true;
497        }
498
499        if pattern.starts_with('*') && pattern.ends_with('*') {
500            let middle = &pattern[1..pattern.len() - 1];
501            return name.contains(middle);
502        }
503
504        if let Some(suffix) = pattern.strip_prefix('*') {
505            return name.ends_with(suffix);
506        }
507
508        if let Some(prefix) = pattern.strip_suffix('*') {
509            return name.starts_with(prefix);
510        }
511
512        name == pattern
513    }
514}
515
516// ============================================================================
517// Output Formatters
518// ============================================================================
519
520/// Generate Prax schema output.
521pub fn format_as_prax(schema: &DatabaseSchema, config: &Config) -> String {
522    let mut output = String::new();
523
524    output.push_str("// Generated by `prax db pull`\n");
525    output.push_str("// Edit this file to customize your schema\n\n");
526
527    output.push_str("datasource db {\n");
528    output.push_str(&format!(
529        "    provider = \"{}\"\n",
530        config.database.provider
531    ));
532    output.push_str("    url      = env(\"DATABASE_URL\")\n");
533    output.push_str("}\n\n");
534
535    output.push_str("generator client {\n");
536    output.push_str("    provider = \"prax-client-rust\"\n");
537    output.push_str("    output   = \"./src/generated\"\n");
538    output.push_str("}\n\n");
539
540    // Use the generate_prax_schema function
541    output.push_str(&generate_prax_schema(schema));
542
543    output
544}
545
546/// Generate JSON output.
547pub fn format_as_json(schema: &DatabaseSchema) -> CliResult<String> {
548    serde_json::to_string_pretty(schema)
549        .map_err(|e| CliError::Config(format!("Failed to serialize schema: {}", e)))
550}
551
552/// Generate SQL DDL output.
553pub fn format_as_sql(schema: &DatabaseSchema, db_type: DatabaseType) -> String {
554    let mut output = String::new();
555
556    output.push_str("-- Generated by `prax db pull`\n");
557    output.push_str(&format!("-- Database: {}\n\n", db_type_name(db_type)));
558
559    // Generate enums (PostgreSQL only)
560    if db_type == DatabaseType::PostgreSQL {
561        for enum_info in &schema.enums {
562            output.push_str(&format!("CREATE TYPE {} AS ENUM (\n", enum_info.name));
563            let values: Vec<String> = enum_info
564                .values
565                .iter()
566                .map(|v| format!("    '{}'", v))
567                .collect();
568            output.push_str(&values.join(",\n"));
569            output.push_str("\n);\n\n");
570        }
571    }
572
573    // Generate tables
574    for table in &schema.tables {
575        output.push_str(&format!(
576            "CREATE TABLE {} (\n",
577            quote_identifier(&table.name, db_type)
578        ));
579
580        let mut col_defs: Vec<String> = Vec::new();
581
582        for col in &table.columns {
583            let mut def = format!(
584                "    {} {}",
585                quote_identifier(&col.name, db_type),
586                col.db_type
587            );
588
589            if !col.nullable {
590                def.push_str(" NOT NULL");
591            }
592
593            if let Some(ref default) = col.default {
594                def.push_str(&format!(" DEFAULT {}", default));
595            }
596
597            col_defs.push(def);
598        }
599
600        // Primary key
601        if !table.primary_key.is_empty() {
602            let pk_cols: Vec<String> = table
603                .primary_key
604                .iter()
605                .map(|c| quote_identifier(c, db_type))
606                .collect();
607            col_defs.push(format!("    PRIMARY KEY ({})", pk_cols.join(", ")));
608        }
609
610        output.push_str(&col_defs.join(",\n"));
611        output.push_str("\n);\n\n");
612
613        // Indexes
614        for idx in &table.indexes {
615            if idx.is_primary {
616                continue;
617            }
618
619            let unique = if idx.is_unique { "UNIQUE " } else { "" };
620            let cols: Vec<String> = idx
621                .columns
622                .iter()
623                .map(|c| quote_identifier(&c.name, db_type))
624                .collect();
625
626            output.push_str(&format!(
627                "CREATE {}INDEX {} ON {} ({});\n",
628                unique,
629                quote_identifier(&idx.name, db_type),
630                quote_identifier(&table.name, db_type),
631                cols.join(", ")
632            ));
633        }
634
635        output.push('\n');
636    }
637
638    output
639}
640
641fn db_type_name(db_type: DatabaseType) -> &'static str {
642    match db_type {
643        DatabaseType::PostgreSQL => "PostgreSQL",
644        DatabaseType::MySQL => "MySQL",
645        DatabaseType::SQLite => "SQLite",
646        DatabaseType::MSSQL => "SQL Server",
647    }
648}
649
650fn quote_identifier(name: &str, db_type: DatabaseType) -> String {
651    match db_type {
652        DatabaseType::PostgreSQL => format!("\"{}\"", name),
653        DatabaseType::MySQL => format!("`{}`", name),
654        DatabaseType::SQLite => format!("\"{}\"", name),
655        DatabaseType::MSSQL => format!("[{}]", name),
656    }
657}