Skip to main content

prax_query/
introspection.rs

1//! Database introspection and schema generation.
2//!
3//! This module provides types for introspecting existing databases and generating
4//! Prax schema definitions from the discovered structure.
5//!
6//! # Database Support
7//!
8//! | Feature              | PostgreSQL | MySQL | SQLite | MSSQL | MongoDB       |
9//! |----------------------|------------|-------|--------|-------|---------------|
10//! | Table introspection  | ✅         | ✅    | ✅     | ✅    | ✅ Collection |
11//! | Column types         | ✅         | ✅    | ✅     | ✅    | ✅ Inferred   |
12//! | Primary keys         | ✅         | ✅    | ✅     | ✅    | ✅ _id        |
13//! | Foreign keys         | ✅         | ✅    | ✅     | ✅    | ❌            |
14//! | Indexes              | ✅         | ✅    | ✅     | ✅    | ✅            |
15//! | Unique constraints   | ✅         | ✅    | ✅     | ✅    | ✅            |
16//! | Default values       | ✅         | ✅    | ✅     | ✅    | ❌            |
17//! | Enums                | ✅         | ✅    | ❌     | ❌    | ❌            |
18//! | Views                | ✅         | ✅    | ✅     | ✅    | ✅            |
19
20use serde::{Deserialize, Serialize};
21
22use crate::sql::{DatabaseType, escape_literal};
23
24// ============================================================================
25// Introspection Results
26// ============================================================================
27
28/// Complete introspection result for a database.
29#[derive(Debug, Clone, Default, Serialize, Deserialize)]
30pub struct DatabaseSchema {
31    /// Database name.
32    pub name: String,
33    /// Schema/namespace (PostgreSQL, MSSQL).
34    pub schema: Option<String>,
35    /// Tables discovered.
36    pub tables: Vec<TableInfo>,
37    /// Views discovered.
38    pub views: Vec<ViewInfo>,
39    /// Enums discovered.
40    pub enums: Vec<EnumInfo>,
41    /// Sequences discovered.
42    pub sequences: Vec<SequenceInfo>,
43}
44
45/// Information about a table.
46#[derive(Debug, Clone, Default, Serialize, Deserialize)]
47pub struct TableInfo {
48    /// Table name.
49    pub name: String,
50    /// Schema/namespace.
51    pub schema: Option<String>,
52    /// Table comment/description.
53    pub comment: Option<String>,
54    /// Columns.
55    pub columns: Vec<ColumnInfo>,
56    /// Primary key columns.
57    pub primary_key: Vec<String>,
58    /// Foreign keys.
59    pub foreign_keys: Vec<ForeignKeyInfo>,
60    /// Indexes.
61    pub indexes: Vec<IndexInfo>,
62    /// Unique constraints.
63    pub unique_constraints: Vec<UniqueConstraint>,
64    /// Check constraints.
65    pub check_constraints: Vec<CheckConstraint>,
66}
67
68/// Information about a column.
69#[derive(Debug, Clone, Default, Serialize, Deserialize)]
70pub struct ColumnInfo {
71    /// Column name.
72    pub name: String,
73    /// Database-specific type name.
74    pub db_type: String,
75    /// Normalized type for schema generation.
76    pub normalized_type: NormalizedType,
77    /// Whether the column is nullable.
78    pub nullable: bool,
79    /// Default value expression.
80    pub default: Option<String>,
81    /// Whether this is an auto-increment/serial column.
82    pub auto_increment: bool,
83    /// Whether this is part of primary key.
84    pub is_primary_key: bool,
85    /// Whether this column has a unique constraint.
86    pub is_unique: bool,
87    /// Column comment.
88    pub comment: Option<String>,
89    /// Character maximum length (for varchar, etc.).
90    pub max_length: Option<i32>,
91    /// Numeric precision.
92    pub precision: Option<i32>,
93    /// Numeric scale.
94    pub scale: Option<i32>,
95    /// Enum type name (if applicable).
96    pub enum_name: Option<String>,
97}
98
99/// Normalized type for cross-database compatibility.
100#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
101pub enum NormalizedType {
102    /// Integer types.
103    Int,
104    BigInt,
105    SmallInt,
106    /// Floating point.
107    Float,
108    Double,
109    /// Fixed precision.
110    Decimal {
111        precision: Option<i32>,
112        scale: Option<i32>,
113    },
114    /// String types.
115    String,
116    Text,
117    Char {
118        length: Option<i32>,
119    },
120    VarChar {
121        length: Option<i32>,
122    },
123    /// Binary.
124    Bytes,
125    /// Boolean.
126    Boolean,
127    /// Date/time.
128    DateTime,
129    Date,
130    Time,
131    Timestamp,
132    /// JSON.
133    Json,
134    /// UUID.
135    Uuid,
136    /// Array of type.
137    Array(Box<NormalizedType>),
138    /// Enum reference.
139    Enum(String),
140    /// Unknown/unsupported.
141    Unknown(String),
142}
143
144impl Default for NormalizedType {
145    fn default() -> Self {
146        Self::Unknown("unknown".to_string())
147    }
148}
149
150impl NormalizedType {
151    /// Convert to Prax schema type string.
152    pub fn to_prax_type(&self) -> String {
153        match self {
154            Self::Int => "Int".to_string(),
155            Self::BigInt => "BigInt".to_string(),
156            Self::SmallInt => "Int".to_string(),
157            Self::Float => "Float".to_string(),
158            Self::Double => "Float".to_string(),
159            Self::Decimal { .. } => "Decimal".to_string(),
160            Self::String | Self::Text | Self::VarChar { .. } | Self::Char { .. } => {
161                "String".to_string()
162            }
163            Self::Bytes => "Bytes".to_string(),
164            Self::Boolean => "Boolean".to_string(),
165            Self::DateTime | Self::Timestamp => "DateTime".to_string(),
166            Self::Date => "DateTime".to_string(),
167            Self::Time => "DateTime".to_string(),
168            Self::Json => "Json".to_string(),
169            Self::Uuid => "String".to_string(), // Or custom UUID type
170            Self::Array(inner) => format!("{}[]", inner.to_prax_type()),
171            // Must match the name `generate_enum` declares the enum under
172            // (also PascalCased) — otherwise a field referencing this enum
173            // points at a type the generated schema never declares.
174            Self::Enum(name) => pascal_case(name),
175            Self::Unknown(t) => format!("Unsupported<{}>", t),
176        }
177    }
178}
179
180/// Information about a foreign key.
181#[derive(Debug, Clone, Default, Serialize, Deserialize)]
182pub struct ForeignKeyInfo {
183    /// Constraint name.
184    pub name: String,
185    /// Local columns.
186    pub columns: Vec<String>,
187    /// Referenced table.
188    pub referenced_table: String,
189    /// Referenced schema.
190    pub referenced_schema: Option<String>,
191    /// Referenced columns.
192    pub referenced_columns: Vec<String>,
193    /// ON DELETE action.
194    pub on_delete: ReferentialAction,
195    /// ON UPDATE action.
196    pub on_update: ReferentialAction,
197}
198
199/// Referential action for foreign keys.
200#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
201pub enum ReferentialAction {
202    #[default]
203    NoAction,
204    Restrict,
205    Cascade,
206    SetNull,
207    SetDefault,
208}
209
210impl ReferentialAction {
211    /// Convert to Prax schema string.
212    pub fn to_prax(&self) -> &'static str {
213        match self {
214            Self::NoAction => "NoAction",
215            Self::Restrict => "Restrict",
216            Self::Cascade => "Cascade",
217            Self::SetNull => "SetNull",
218            Self::SetDefault => "SetDefault",
219        }
220    }
221
222    /// Parse from database string.
223    pub fn from_str(s: &str) -> Self {
224        match s.to_uppercase().as_str() {
225            "NO ACTION" | "NOACTION" => Self::NoAction,
226            "RESTRICT" => Self::Restrict,
227            "CASCADE" => Self::Cascade,
228            "SET NULL" | "SETNULL" => Self::SetNull,
229            "SET DEFAULT" | "SETDEFAULT" => Self::SetDefault,
230            _ => Self::NoAction,
231        }
232    }
233}
234
235/// Information about an index.
236#[derive(Debug, Clone, Default, Serialize, Deserialize)]
237pub struct IndexInfo {
238    /// Index name.
239    pub name: String,
240    /// Columns in the index.
241    pub columns: Vec<IndexColumn>,
242    /// Whether this is a unique index.
243    pub is_unique: bool,
244    /// Whether this is a primary key index.
245    pub is_primary: bool,
246    /// Index type (btree, hash, gin, etc.).
247    pub index_type: Option<String>,
248    /// Filter condition (partial index).
249    pub filter: Option<String>,
250}
251
252/// A column in an index.
253#[derive(Debug, Clone, Default, Serialize, Deserialize)]
254pub struct IndexColumn {
255    /// Column name.
256    pub name: String,
257    /// Sort order.
258    pub order: SortOrder,
259    /// Nulls position.
260    pub nulls: NullsOrder,
261}
262
263/// Sort order for index columns.
264#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
265pub enum SortOrder {
266    #[default]
267    Asc,
268    Desc,
269}
270
271/// Nulls ordering for index columns.
272#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
273pub enum NullsOrder {
274    #[default]
275    Last,
276    First,
277}
278
279/// Unique constraint information.
280#[derive(Debug, Clone, Default, Serialize, Deserialize)]
281pub struct UniqueConstraint {
282    /// Constraint name.
283    pub name: String,
284    /// Columns.
285    pub columns: Vec<String>,
286}
287
288/// Check constraint information.
289#[derive(Debug, Clone, Default, Serialize, Deserialize)]
290pub struct CheckConstraint {
291    /// Constraint name.
292    pub name: String,
293    /// Check expression.
294    pub expression: String,
295}
296
297/// Information about a view.
298#[derive(Debug, Clone, Default, Serialize, Deserialize)]
299pub struct ViewInfo {
300    /// View name.
301    pub name: String,
302    /// Schema.
303    pub schema: Option<String>,
304    /// View definition SQL.
305    pub definition: Option<String>,
306    /// Whether this is a materialized view.
307    pub is_materialized: bool,
308    /// Columns (inferred from definition).
309    pub columns: Vec<ColumnInfo>,
310}
311
312/// Information about an enum type.
313#[derive(Debug, Clone, Default, Serialize, Deserialize)]
314pub struct EnumInfo {
315    /// Enum type name.
316    pub name: String,
317    /// Schema.
318    pub schema: Option<String>,
319    /// Enum values.
320    pub values: Vec<String>,
321}
322
323/// Information about a sequence.
324#[derive(Debug, Clone, Default, Serialize, Deserialize)]
325pub struct SequenceInfo {
326    /// Sequence name.
327    pub name: String,
328    /// Schema.
329    pub schema: Option<String>,
330    /// Start value.
331    pub start: i64,
332    /// Increment.
333    pub increment: i64,
334    /// Minimum value.
335    pub min_value: Option<i64>,
336    /// Maximum value.
337    pub max_value: Option<i64>,
338    /// Whether it cycles.
339    pub cycle: bool,
340}
341
342// ============================================================================
343// Introspection Queries
344// ============================================================================
345
346/// SQL queries for database introspection.
347pub mod queries {
348    use super::*;
349
350    /// Get tables query.
351    pub fn tables_query(db_type: DatabaseType, schema: Option<&str>) -> String {
352        match db_type {
353            DatabaseType::PostgreSQL => {
354                let schema_filter = escape_literal(schema.unwrap_or("public"));
355                format!(
356                    "SELECT table_name, obj_description((quote_ident(table_schema) || '.' || quote_ident(table_name))::regclass) as comment \
357                     FROM information_schema.tables \
358                     WHERE table_schema = '{}' AND table_type = 'BASE TABLE' \
359                     ORDER BY table_name",
360                    schema_filter
361                )
362            }
363            DatabaseType::MySQL => {
364                let schema_filter = schema
365                    .map(|s| format!("AND table_schema = '{}'", escape_literal(s)))
366                    .unwrap_or_default();
367                format!(
368                    "SELECT table_name, table_comment as comment \
369                     FROM information_schema.tables \
370                     WHERE table_type = 'BASE TABLE' {} \
371                     ORDER BY table_name",
372                    schema_filter
373                )
374            }
375            DatabaseType::SQLite => "SELECT name as table_name, NULL as comment \
376                 FROM sqlite_master \
377                 WHERE type = 'table' AND name NOT LIKE 'sqlite_%' \
378                 ORDER BY name"
379                .to_string(),
380            DatabaseType::MSSQL => {
381                let schema_filter = escape_literal(schema.unwrap_or("dbo"));
382                format!(
383                    "SELECT t.name as table_name, CAST(ep.value AS NVARCHAR(MAX)) as comment \
384                     FROM sys.tables t \
385                     LEFT JOIN sys.extended_properties ep ON ep.major_id = t.object_id AND ep.minor_id = 0 AND ep.name = 'MS_Description' \
386                     JOIN sys.schemas s ON t.schema_id = s.schema_id \
387                     WHERE s.name = '{}' \
388                     ORDER BY t.name",
389                    schema_filter
390                )
391            }
392        }
393    }
394
395    /// Get columns query.
396    pub fn columns_query(db_type: DatabaseType, table: &str, schema: Option<&str>) -> String {
397        let table = escape_literal(table);
398        match db_type {
399            DatabaseType::PostgreSQL => {
400                let schema_filter = escape_literal(schema.unwrap_or("public"));
401                format!(
402                    "SELECT \
403                        c.column_name, \
404                        c.data_type, \
405                        c.udt_name, \
406                        c.is_nullable = 'YES' as nullable, \
407                        c.column_default, \
408                        c.character_maximum_length, \
409                        c.numeric_precision, \
410                        c.numeric_scale, \
411                        col_description((quote_ident(c.table_schema) || '.' || quote_ident(c.table_name))::regclass, c.ordinal_position) as comment, \
412                        CASE WHEN c.column_default LIKE 'nextval%' THEN true ELSE false END as auto_increment \
413                     FROM information_schema.columns c \
414                     WHERE c.table_schema = '{}' AND c.table_name = '{}' \
415                     ORDER BY c.ordinal_position",
416                    schema_filter, table
417                )
418            }
419            DatabaseType::MySQL => {
420                format!(
421                    "SELECT \
422                        column_name, \
423                        data_type, \
424                        column_type as udt_name, \
425                        is_nullable = 'YES' as nullable, \
426                        column_default, \
427                        character_maximum_length, \
428                        numeric_precision, \
429                        numeric_scale, \
430                        column_comment as comment, \
431                        extra LIKE '%auto_increment%' as auto_increment \
432                     FROM information_schema.columns \
433                     WHERE table_name = '{}' {} \
434                     ORDER BY ordinal_position",
435                    table,
436                    schema
437                        .map(|s| format!("AND table_schema = '{}'", escape_literal(s)))
438                        .unwrap_or_default()
439                )
440            }
441            DatabaseType::SQLite => {
442                format!("PRAGMA table_info('{}')", table)
443            }
444            DatabaseType::MSSQL => {
445                let schema_filter = escape_literal(schema.unwrap_or("dbo"));
446                format!(
447                    "SELECT \
448                        c.name as column_name, \
449                        t.name as data_type, \
450                        t.name as udt_name, \
451                        c.is_nullable as nullable, \
452                        dc.definition as column_default, \
453                        c.max_length as character_maximum_length, \
454                        c.precision as numeric_precision, \
455                        c.scale as numeric_scale, \
456                        CAST(ep.value AS NVARCHAR(MAX)) as comment, \
457                        c.is_identity as auto_increment \
458                     FROM sys.columns c \
459                     JOIN sys.types t ON c.user_type_id = t.user_type_id \
460                     JOIN sys.tables tb ON c.object_id = tb.object_id \
461                     JOIN sys.schemas s ON tb.schema_id = s.schema_id \
462                     LEFT JOIN sys.default_constraints dc ON c.default_object_id = dc.object_id \
463                     LEFT JOIN sys.extended_properties ep ON ep.major_id = c.object_id AND ep.minor_id = c.column_id AND ep.name = 'MS_Description' \
464                     WHERE tb.name = '{}' AND s.name = '{}' \
465                     ORDER BY c.column_id",
466                    table, schema_filter
467                )
468            }
469        }
470    }
471
472    /// Get primary keys query.
473    pub fn primary_keys_query(db_type: DatabaseType, table: &str, schema: Option<&str>) -> String {
474        let table = escape_literal(table);
475        match db_type {
476            DatabaseType::PostgreSQL => {
477                let schema_filter = escape_literal(schema.unwrap_or("public"));
478                format!(
479                    "SELECT a.attname as column_name \
480                     FROM pg_index i \
481                     JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey) \
482                     JOIN pg_class c ON c.oid = i.indrelid \
483                     JOIN pg_namespace n ON n.oid = c.relnamespace \
484                     WHERE i.indisprimary AND c.relname = '{}' AND n.nspname = '{}' \
485                     ORDER BY array_position(i.indkey, a.attnum)",
486                    table, schema_filter
487                )
488            }
489            DatabaseType::MySQL => {
490                format!(
491                    "SELECT column_name \
492                     FROM information_schema.key_column_usage \
493                     WHERE constraint_name = 'PRIMARY' AND table_name = '{}' {} \
494                     ORDER BY ordinal_position",
495                    table,
496                    schema
497                        .map(|s| format!("AND table_schema = '{}'", escape_literal(s)))
498                        .unwrap_or_default()
499                )
500            }
501            DatabaseType::SQLite => {
502                format!("PRAGMA table_info('{}')", table) // Filter pk column in result
503            }
504            DatabaseType::MSSQL => {
505                let schema_filter = escape_literal(schema.unwrap_or("dbo"));
506                format!(
507                    "SELECT c.name as column_name \
508                     FROM sys.indexes i \
509                     JOIN sys.index_columns ic ON i.object_id = ic.object_id AND i.index_id = ic.index_id \
510                     JOIN sys.columns c ON ic.object_id = c.object_id AND ic.column_id = c.column_id \
511                     JOIN sys.tables t ON i.object_id = t.object_id \
512                     JOIN sys.schemas s ON t.schema_id = s.schema_id \
513                     WHERE i.is_primary_key = 1 AND t.name = '{}' AND s.name = '{}' \
514                     ORDER BY ic.key_ordinal",
515                    table, schema_filter
516                )
517            }
518        }
519    }
520
521    /// Get foreign keys query.
522    pub fn foreign_keys_query(db_type: DatabaseType, table: &str, schema: Option<&str>) -> String {
523        let table = escape_literal(table);
524        match db_type {
525            DatabaseType::PostgreSQL => {
526                let schema_filter = escape_literal(schema.unwrap_or("public"));
527                format!(
528                    "SELECT \
529                        tc.constraint_name, \
530                        kcu.column_name, \
531                        ccu.table_name as referenced_table, \
532                        ccu.table_schema as referenced_schema, \
533                        ccu.column_name as referenced_column, \
534                        rc.delete_rule, \
535                        rc.update_rule \
536                     FROM information_schema.table_constraints tc \
537                     JOIN information_schema.key_column_usage kcu ON tc.constraint_name = kcu.constraint_name \
538                     JOIN information_schema.constraint_column_usage ccu ON ccu.constraint_name = tc.constraint_name \
539                     JOIN information_schema.referential_constraints rc ON rc.constraint_name = tc.constraint_name \
540                     WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_name = '{}' AND tc.table_schema = '{}' \
541                     ORDER BY tc.constraint_name, kcu.ordinal_position",
542                    table, schema_filter
543                )
544            }
545            DatabaseType::MySQL => {
546                format!(
547                    "SELECT \
548                        constraint_name, \
549                        column_name, \
550                        referenced_table_name as referenced_table, \
551                        referenced_table_schema as referenced_schema, \
552                        referenced_column_name as referenced_column, \
553                        'NO ACTION' as delete_rule, \
554                        'NO ACTION' as update_rule \
555                     FROM information_schema.key_column_usage \
556                     WHERE referenced_table_name IS NOT NULL AND table_name = '{}' {} \
557                     ORDER BY constraint_name, ordinal_position",
558                    table,
559                    schema
560                        .map(|s| format!("AND table_schema = '{}'", escape_literal(s)))
561                        .unwrap_or_default()
562                )
563            }
564            DatabaseType::SQLite => {
565                format!("PRAGMA foreign_key_list('{}')", table)
566            }
567            DatabaseType::MSSQL => {
568                let schema_filter = escape_literal(schema.unwrap_or("dbo"));
569                format!(
570                    "SELECT \
571                        fk.name as constraint_name, \
572                        c.name as column_name, \
573                        rt.name as referenced_table, \
574                        rs.name as referenced_schema, \
575                        rc.name as referenced_column, \
576                        fk.delete_referential_action_desc as delete_rule, \
577                        fk.update_referential_action_desc as update_rule \
578                     FROM sys.foreign_keys fk \
579                     JOIN sys.foreign_key_columns fkc ON fk.object_id = fkc.constraint_object_id \
580                     JOIN sys.columns c ON fkc.parent_object_id = c.object_id AND fkc.parent_column_id = c.column_id \
581                     JOIN sys.tables t ON fk.parent_object_id = t.object_id \
582                     JOIN sys.schemas s ON t.schema_id = s.schema_id \
583                     JOIN sys.tables rt ON fk.referenced_object_id = rt.object_id \
584                     JOIN sys.schemas rs ON rt.schema_id = rs.schema_id \
585                     JOIN sys.columns rc ON fkc.referenced_object_id = rc.object_id AND fkc.referenced_column_id = rc.column_id \
586                     WHERE t.name = '{}' AND s.name = '{}' \
587                     ORDER BY fk.name",
588                    table, schema_filter
589                )
590            }
591        }
592    }
593
594    /// Get indexes query.
595    pub fn indexes_query(db_type: DatabaseType, table: &str, schema: Option<&str>) -> String {
596        let table = escape_literal(table);
597        match db_type {
598            DatabaseType::PostgreSQL => {
599                let schema_filter = escape_literal(schema.unwrap_or("public"));
600                format!(
601                    "SELECT \
602                        i.relname as index_name, \
603                        a.attname as column_name, \
604                        ix.indisunique as is_unique, \
605                        ix.indisprimary as is_primary, \
606                        am.amname as index_type, \
607                        pg_get_expr(ix.indpred, ix.indrelid) as filter \
608                     FROM pg_index ix \
609                     JOIN pg_class t ON t.oid = ix.indrelid \
610                     JOIN pg_class i ON i.oid = ix.indexrelid \
611                     JOIN pg_namespace n ON n.oid = t.relnamespace \
612                     JOIN pg_am am ON i.relam = am.oid \
613                     JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(ix.indkey) \
614                     WHERE t.relname = '{}' AND n.nspname = '{}' \
615                     ORDER BY i.relname, array_position(ix.indkey, a.attnum)",
616                    table, schema_filter
617                )
618            }
619            DatabaseType::MySQL => {
620                format!(
621                    "SELECT \
622                        index_name, \
623                        column_name, \
624                        NOT non_unique as is_unique, \
625                        index_name = 'PRIMARY' as is_primary, \
626                        index_type, \
627                        NULL as filter \
628                     FROM information_schema.statistics \
629                     WHERE table_name = '{}' {} \
630                     ORDER BY index_name, seq_in_index",
631                    table,
632                    schema
633                        .map(|s| format!("AND table_schema = '{}'", escape_literal(s)))
634                        .unwrap_or_default()
635                )
636            }
637            DatabaseType::SQLite => {
638                format!("PRAGMA index_list('{}')", table)
639            }
640            DatabaseType::MSSQL => {
641                let schema_filter = escape_literal(schema.unwrap_or("dbo"));
642                format!(
643                    "SELECT \
644                        i.name as index_name, \
645                        c.name as column_name, \
646                        i.is_unique, \
647                        i.is_primary_key as is_primary, \
648                        i.type_desc as index_type, \
649                        i.filter_definition as filter \
650                     FROM sys.indexes i \
651                     JOIN sys.index_columns ic ON i.object_id = ic.object_id AND i.index_id = ic.index_id \
652                     JOIN sys.columns c ON ic.object_id = c.object_id AND ic.column_id = c.column_id \
653                     JOIN sys.tables t ON i.object_id = t.object_id \
654                     JOIN sys.schemas s ON t.schema_id = s.schema_id \
655                     WHERE t.name = '{}' AND s.name = '{}' AND i.name IS NOT NULL \
656                     ORDER BY i.name, ic.key_ordinal",
657                    table, schema_filter
658                )
659            }
660        }
661    }
662
663    /// Get enums query (PostgreSQL only).
664    pub fn enums_query(schema: Option<&str>) -> String {
665        let schema_filter = escape_literal(schema.unwrap_or("public"));
666        format!(
667            "SELECT t.typname as enum_name, e.enumlabel as enum_value \
668             FROM pg_type t \
669             JOIN pg_enum e ON t.oid = e.enumtypid \
670             JOIN pg_namespace n ON n.oid = t.typnamespace \
671             WHERE n.nspname = '{}' \
672             ORDER BY t.typname, e.enumsortorder",
673            schema_filter
674        )
675    }
676
677    /// Get views query.
678    pub fn views_query(db_type: DatabaseType, schema: Option<&str>) -> String {
679        match db_type {
680            DatabaseType::PostgreSQL => {
681                let schema_filter = escape_literal(schema.unwrap_or("public"));
682                format!(
683                    "SELECT table_name as view_name, view_definition, false as is_materialized \
684                     FROM information_schema.views \
685                     WHERE table_schema = '{}' \
686                     UNION ALL \
687                     SELECT matviewname as view_name, definition as view_definition, true as is_materialized \
688                     FROM pg_matviews \
689                     WHERE schemaname = '{}' \
690                     ORDER BY view_name",
691                    schema_filter, schema_filter
692                )
693            }
694            DatabaseType::MySQL => {
695                format!(
696                    "SELECT table_name as view_name, view_definition, false as is_materialized \
697                     FROM information_schema.views \
698                     WHERE table_schema = '{}' \
699                     ORDER BY view_name",
700                    escape_literal(schema.unwrap_or("information_schema"))
701                )
702            }
703            DatabaseType::SQLite => {
704                "SELECT name as view_name, sql as view_definition, 0 as is_materialized \
705                 FROM sqlite_master \
706                 WHERE type = 'view' \
707                 ORDER BY name"
708                    .to_string()
709            }
710            DatabaseType::MSSQL => {
711                let schema_filter = escape_literal(schema.unwrap_or("dbo"));
712                format!(
713                    "SELECT v.name as view_name, m.definition as view_definition, \
714                     CASE WHEN i.object_id IS NOT NULL THEN 1 ELSE 0 END as is_materialized \
715                     FROM sys.views v \
716                     JOIN sys.schemas s ON v.schema_id = s.schema_id \
717                     JOIN sys.sql_modules m ON v.object_id = m.object_id \
718                     LEFT JOIN sys.indexes i ON v.object_id = i.object_id AND i.index_id = 1 \
719                     WHERE s.name = '{}' \
720                     ORDER BY v.name",
721                    schema_filter
722                )
723            }
724        }
725    }
726}
727
728// ============================================================================
729// Type Mapping
730// ============================================================================
731
732/// Map database types to normalized types.
733///
734/// ⚠️ MySQL `enum(...)` columns are NOT resolved here: synthesizing the
735/// enum's name needs table/column context this function never sees, so a
736/// raw `COLUMN_TYPE` like `"enum('a','b')"` falls through to
737/// `Unknown`. Callers must intercept `data_type.eq_ignore_ascii_case("enum")`
738/// *before* calling this and read the value list with
739/// [`parse_mysql_enum_values`] instead — see `prax-cli`'s MySQL
740/// introspector (`commands::introspect::mysql::populate_table`), the only
741/// in-repo caller that handles enum columns. Adding a MySQL type here that
742/// also needs table/column context (e.g. `set(...)`) requires a second
743/// interception point there too.
744pub fn normalize_type(
745    db_type: DatabaseType,
746    type_name: &str,
747    max_length: Option<i32>,
748    precision: Option<i32>,
749    scale: Option<i32>,
750) -> NormalizedType {
751    let type_lower = type_name.to_lowercase();
752
753    match db_type {
754        DatabaseType::PostgreSQL => {
755            normalize_postgres_type(&type_lower, max_length, precision, scale)
756        }
757        DatabaseType::MySQL => normalize_mysql_type(&type_lower, max_length, precision, scale),
758        DatabaseType::SQLite => normalize_sqlite_type(&type_lower),
759        DatabaseType::MSSQL => normalize_mssql_type(&type_lower, max_length, precision, scale),
760    }
761}
762
763fn normalize_postgres_type(
764    type_name: &str,
765    _max_length: Option<i32>,
766    precision: Option<i32>,
767    scale: Option<i32>,
768) -> NormalizedType {
769    match type_name {
770        "int2" | "smallint" | "smallserial" => NormalizedType::SmallInt,
771        "int4" | "integer" | "int" | "serial" => NormalizedType::Int,
772        "int8" | "bigint" | "bigserial" => NormalizedType::BigInt,
773        "real" | "float4" => NormalizedType::Float,
774        "double precision" | "float8" => NormalizedType::Double,
775        "numeric" | "decimal" => NormalizedType::Decimal { precision, scale },
776        "bool" | "boolean" => NormalizedType::Boolean,
777        "text" => NormalizedType::Text,
778        "varchar" | "character varying" => NormalizedType::VarChar {
779            length: _max_length,
780        },
781        "char" | "character" | "bpchar" => NormalizedType::Char {
782            length: _max_length,
783        },
784        "bytea" => NormalizedType::Bytes,
785        "timestamp" | "timestamp without time zone" => NormalizedType::Timestamp,
786        "timestamptz" | "timestamp with time zone" => NormalizedType::DateTime,
787        "date" => NormalizedType::Date,
788        "time" | "time without time zone" | "timetz" | "time with time zone" => {
789            NormalizedType::Time
790        }
791        "json" | "jsonb" => NormalizedType::Json,
792        "uuid" => NormalizedType::Uuid,
793        t if t.ends_with("[]") => {
794            let inner = normalize_postgres_type(&t[..t.len() - 2], None, None, None);
795            NormalizedType::Array(Box::new(inner))
796        }
797        t => NormalizedType::Unknown(t.to_string()),
798    }
799}
800
801fn normalize_mysql_type(
802    type_name: &str,
803    max_length: Option<i32>,
804    precision: Option<i32>,
805    scale: Option<i32>,
806) -> NormalizedType {
807    match type_name {
808        "tinyint" | "smallint" => NormalizedType::SmallInt,
809        "int" | "integer" | "mediumint" => NormalizedType::Int,
810        "bigint" => NormalizedType::BigInt,
811        "float" => NormalizedType::Float,
812        "double" | "real" => NormalizedType::Double,
813        "decimal" | "numeric" => NormalizedType::Decimal { precision, scale },
814        "bit" | "bool" | "boolean" => NormalizedType::Boolean,
815        "text" | "mediumtext" | "longtext" => NormalizedType::Text,
816        "varchar" => NormalizedType::VarChar { length: max_length },
817        "char" => NormalizedType::Char { length: max_length },
818        "tinyblob" | "blob" | "mediumblob" | "longblob" | "binary" | "varbinary" => {
819            NormalizedType::Bytes
820        }
821        "datetime" | "timestamp" => NormalizedType::DateTime,
822        "date" => NormalizedType::Date,
823        "time" => NormalizedType::Time,
824        "json" => NormalizedType::Json,
825        // Enum columns need table/column context to synthesize a name and
826        // aren't normalized here — `prax-cli`'s MySQL introspector
827        // (`commands::introspect::mysql::populate_table`) intercepts
828        // `data_type.eq_ignore_ascii_case("enum")` columns *before* calling
829        // this function, reading `COLUMN_TYPE` (passed here as `udt_name`,
830        // never as `type_name`) via `parse_mysql_enum_values`. Adding a new
831        // MySQL type here that also needs table/column context (e.g.
832        // `set(...)`) requires a second interception point there too — this
833        // match alone never sees enough context to build one.
834        t => NormalizedType::Unknown(t.to_string()),
835    }
836}
837
838/// Parse the quoted value list out of a MySQL `COLUMN_TYPE` enum string, e.g.
839/// `"enum('active','inactive')"` -> `["active", "inactive"]`. Handles the
840/// `''`-escaped quote MySQL uses for a literal `'` inside a value.
841pub fn parse_mysql_enum_values(column_type: &str) -> Vec<String> {
842    let trimmed = column_type.trim();
843    // MySQL always reports COLUMN_TYPE with a lowercase `enum` keyword, but
844    // match case-insensitively anyway since the caller detects the column
845    // via `data_type.eq_ignore_ascii_case("enum")`. `get(..5)` (not a raw
846    // byte-range index) avoids panicking on non-ASCII input shorter than 5
847    // bytes or whose byte offset 5 isn't a UTF-8 char boundary — this is a
848    // public function callable with arbitrary strings.
849    let inner = match trimmed.get(..5) {
850        Some(prefix) if prefix.eq_ignore_ascii_case("enum(") => {
851            trimmed[5..].strip_suffix(')').unwrap_or("")
852        }
853        _ => "",
854    };
855
856    let mut values = Vec::new();
857    let mut chars = inner.chars().peekable();
858    while let Some(c) = chars.next() {
859        if c != '\'' {
860            continue;
861        }
862        let mut value = String::new();
863        while let Some(next) = chars.next() {
864            if next == '\'' {
865                if chars.peek() == Some(&'\'') {
866                    value.push('\'');
867                    chars.next();
868                    continue;
869                }
870                break;
871            }
872            value.push(next);
873        }
874        values.push(value);
875    }
876    values
877}
878
879fn normalize_sqlite_type(type_name: &str) -> NormalizedType {
880    // SQLite has dynamic typing, so we map by affinity
881    match type_name {
882        "integer" | "int" => NormalizedType::Int,
883        "real" | "float" | "double" => NormalizedType::Double,
884        "text" | "varchar" | "char" | "clob" => NormalizedType::Text,
885        "blob" => NormalizedType::Bytes,
886        "boolean" | "bool" => NormalizedType::Boolean,
887        "datetime" | "timestamp" | "date" | "time" => NormalizedType::DateTime,
888        t => NormalizedType::Unknown(t.to_string()),
889    }
890}
891
892fn normalize_mssql_type(
893    type_name: &str,
894    max_length: Option<i32>,
895    precision: Option<i32>,
896    scale: Option<i32>,
897) -> NormalizedType {
898    match type_name {
899        "tinyint" | "smallint" => NormalizedType::SmallInt,
900        "int" => NormalizedType::Int,
901        "bigint" => NormalizedType::BigInt,
902        "real" | "float" => NormalizedType::Float,
903        "decimal" | "numeric" | "money" | "smallmoney" => {
904            NormalizedType::Decimal { precision, scale }
905        }
906        "bit" => NormalizedType::Boolean,
907        "text" | "ntext" => NormalizedType::Text,
908        "varchar" | "nvarchar" => NormalizedType::VarChar { length: max_length },
909        "char" | "nchar" => NormalizedType::Char { length: max_length },
910        "binary" | "varbinary" | "image" => NormalizedType::Bytes,
911        "datetime" | "datetime2" | "datetimeoffset" | "smalldatetime" => NormalizedType::DateTime,
912        "date" => NormalizedType::Date,
913        "time" => NormalizedType::Time,
914        "uniqueidentifier" => NormalizedType::Uuid,
915        t => NormalizedType::Unknown(t.to_string()),
916    }
917}
918
919// ============================================================================
920// Schema Generation
921// ============================================================================
922
923/// Generate Prax schema from introspection result.
924pub fn generate_prax_schema(db: &DatabaseSchema) -> String {
925    let mut output = String::new();
926
927    // Header comment
928    output.push_str("// Generated by Prax introspection\n");
929    output.push_str(&format!("// Database: {}\n\n", db.name));
930
931    // Generate enums
932    for enum_info in &db.enums {
933        output.push_str(&generate_enum(enum_info));
934        output.push('\n');
935    }
936
937    // Generate models
938    for table in &db.tables {
939        output.push_str(&generate_model(table, &db.tables));
940        output.push('\n');
941    }
942
943    // Generate views
944    for view in &db.views {
945        output.push_str(&generate_view(view));
946        output.push('\n');
947    }
948
949    output
950}
951
952fn generate_enum(enum_info: &EnumInfo) -> String {
953    // PascalCase the name to match the diff-source builder's
954    // `to_pascal_case`, so a re-introspected source enum has the same name
955    // as the one just written here (otherwise every diff proposes dropping
956    // one and adding the other, forever).
957    let mut output = format!("enum {} {{\n", pascal_case(&enum_info.name));
958    let sanitized = sanitize_variants(&enum_info.values);
959    for (raw, value) in enum_info.values.iter().zip(sanitized) {
960        // `EnumVariant::db_value()` falls back to the variant name when no
961        // `@map` is present — the same fallback gap `@@map` above fixes for
962        // the enum's own name. Pin a raw value that needed sanitizing (e.g.
963        // MySQL's `"in-progress"` -> `in_progress`), or the diff source
964        // built from this enum uses the sanitized name instead of the
965        // value actually stored in the database.
966        if value == *raw {
967            output.push_str(&format!("    {}\n", value));
968        } else {
969            output.push_str(&format!(
970                "    {} @map(\"{}\")\n",
971                value,
972                escape_map_value(raw)
973            ));
974        }
975    }
976    // Always pin the real DB type name with @@map, mirroring
977    // `prax_migrate::introspect::build_enum` (and `generate_model`'s
978    // `@@map` for tables). `Enum::database_name()` falls back to the
979    // (PascalCased) enum name when no `@@map` is present, so without this
980    // a Postgres enum `user_role` would generate/diff SQL against a type
981    // named `UserRole` — which doesn't exist in the live database — while
982    // the real `user_role` type is left untouched.
983    output.push_str(&format!(
984        "    @@map(\"{}\")\n",
985        escape_map_value(&enum_info.name)
986    ));
987    output.push_str("}\n");
988    output
989}
990
991/// Sanitize each of an enum's raw values, disambiguating any that collide
992/// after sanitization (e.g. `"in-progress"` and `"in_progress"` both map to
993/// `in_progress`) with a numeric suffix so no enum ends up with two
994/// identically-named variants.
995///
996/// Mirrors `prax_migrate::introspect`'s identically-named helper — both must
997/// apply the same transform, in the same order, to the same
998/// `EnumInfo::values`, so a re-introspected diff source's variant names
999/// match what was written to disk here.
1000pub fn sanitize_variants(values: &[String]) -> Vec<String> {
1001    let mut seen = std::collections::HashSet::with_capacity(values.len());
1002    values
1003        .iter()
1004        .map(|raw| disambiguate(&sanitize_identifier(raw), |c| c.to_string(), &mut seen))
1005        .collect()
1006}
1007
1008/// Append a numeric suffix to `base` until `key(candidate)` hasn't been
1009/// reserved in `used_keys` yet, reserving it and returning that candidate.
1010/// Shared by every "two different inputs must not resolve to the same
1011/// declared name" case in this module (and by `prax-cli`'s
1012/// `reserve_unique_enum_name`, since that crate already depends on this
1013/// one) — `key` lets a caller dedupe on a transformed form of the candidate
1014/// (e.g. its `PascalCase`) while still returning the untransformed one.
1015pub fn disambiguate(
1016    base: &str,
1017    mut key: impl FnMut(&str) -> String,
1018    used_keys: &mut std::collections::HashSet<String>,
1019) -> String {
1020    let mut suffix = 2;
1021    let mut candidate = base.to_string();
1022    loop {
1023        if used_keys.insert(key(&candidate)) {
1024            return candidate;
1025        }
1026        candidate = format!("{}_{}", base, suffix);
1027        suffix += 1;
1028    }
1029}
1030
1031/// Sanitize a raw introspected value into a legal `.prax` identifier
1032/// (`ASCII_ALPHA (ASCII_ALPHANUMERIC | '_')*`). A MySQL enum value can be
1033/// arbitrary text (`"in-progress"`, `"1"`, `""`), none of which the schema
1034/// grammar's `identifier` rule accepts verbatim.
1035///
1036/// ⚠️ Duplicated verbatim as `prax_migrate::introspect::sanitize_identifier`
1037/// — `prax-migrate` depends only on `prax-schema`, not on this crate, so it
1038/// can't call this copy directly. Change the transform in both places, or
1039/// `db pull`'s written schema and `migrate dev`'s diff source will
1040/// sanitize the same raw value differently and churn forever.
1041pub fn sanitize_identifier(raw: &str) -> String {
1042    let mapped: String = raw
1043        .chars()
1044        .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
1045        .collect();
1046    match mapped.chars().next() {
1047        Some(c) if c.is_ascii_alphabetic() => mapped,
1048        Some(_) => format!("V{}", mapped),
1049        None => "V".to_string(),
1050    }
1051}
1052
1053/// Make a raw value safe to embed in a `.prax` string literal
1054/// (`@map("...")`/`@@map("...")`). Mirrors the grammar's `string_content`
1055/// escape rules (`\"` and `\\`, see `prax-schema`'s `escape_prax_string` —
1056/// duplicated here because this crate must not depend on `prax-schema`):
1057/// backslashes first, then quotes, so the written file parses back to the
1058/// exact raw value. Never substitute characters — a MySQL enum value is
1059/// arbitrary text and the diff source must carry it verbatim.
1060fn escape_map_value(raw: &str) -> String {
1061    raw.replace('\\', "\\\\").replace('"', "\\\"")
1062}
1063
1064fn generate_model(table: &TableInfo, all_tables: &[TableInfo]) -> String {
1065    let mut output = String::new();
1066
1067    // Comment
1068    if let Some(ref comment) = table.comment {
1069        output.push_str(&format!("/// {}\n", comment));
1070    }
1071
1072    output.push_str(&format!("model {} {{\n", pascal_case(&table.name)));
1073
1074    // Fields
1075    for col in &table.columns {
1076        output.push_str(&generate_field(col, &table.primary_key));
1077    }
1078
1079    // Relations
1080    for fk in &table.foreign_keys {
1081        output.push_str(&generate_relation(fk, all_tables));
1082    }
1083
1084    // Model attributes
1085    let attrs = generate_model_attributes(table);
1086    if !attrs.is_empty() {
1087        output.push('\n');
1088        output.push_str(&attrs);
1089    }
1090
1091    output.push_str("}\n");
1092    output
1093}
1094
1095fn generate_field(col: &ColumnInfo, primary_key: &[String]) -> String {
1096    let mut attrs = Vec::new();
1097
1098    // Check if primary key
1099    if primary_key.contains(&col.name) {
1100        attrs.push("@id".to_string());
1101    }
1102
1103    // Auto increment
1104    if col.auto_increment {
1105        attrs.push("@auto".to_string());
1106    }
1107
1108    // Unique
1109    if col.is_unique && !primary_key.contains(&col.name) {
1110        attrs.push("@unique".to_string());
1111    }
1112
1113    // Default
1114    if let Some(ref default) = col.default
1115        && !col.auto_increment
1116    {
1117        let default_val = simplify_default(default);
1118        attrs.push(format!("@default({})", default_val));
1119    }
1120
1121    // Map if name differs
1122    let field_name = camel_case(&col.name);
1123    if field_name != col.name {
1124        attrs.push(format!("@map(\"{}\")", escape_map_value(&col.name)));
1125    }
1126
1127    // Build type string
1128    let type_str = col.normalized_type.to_prax_type();
1129    let optional = if col.nullable { "?" } else { "" };
1130
1131    let attrs_str = if attrs.is_empty() {
1132        String::new()
1133    } else {
1134        format!(" {}", attrs.join(" "))
1135    };
1136
1137    format!("    {} {}{}{}\n", field_name, type_str, optional, attrs_str)
1138}
1139
1140fn generate_relation(fk: &ForeignKeyInfo, all_tables: &[TableInfo]) -> String {
1141    // Find the referenced table
1142    let _ref_table = all_tables.iter().find(|t| t.name == fk.referenced_table);
1143    let ref_name = pascal_case(&fk.referenced_table);
1144
1145    let field_name = if fk.columns.len() == 1 {
1146        // Derive relation name from FK column (e.g., user_id -> user)
1147        let col = &fk.columns[0];
1148        if col.ends_with("_id") {
1149            camel_case(&col[..col.len() - 3])
1150        } else {
1151            camel_case(&fk.referenced_table)
1152        }
1153    } else {
1154        camel_case(&fk.referenced_table)
1155    };
1156
1157    let mut attrs = [format!(
1158        "@relation(fields: [{}], references: [{}]",
1159        fk.columns
1160            .iter()
1161            .map(|c| camel_case(c))
1162            .collect::<Vec<_>>()
1163            .join(", "),
1164        fk.referenced_columns
1165            .iter()
1166            .map(|c| camel_case(c))
1167            .collect::<Vec<_>>()
1168            .join(", ")
1169    )];
1170
1171    // Add referential actions if not default
1172    if fk.on_delete != ReferentialAction::NoAction {
1173        attrs[0].push_str(&format!(", onDelete: {}", fk.on_delete.to_prax()));
1174    }
1175    if fk.on_update != ReferentialAction::NoAction {
1176        attrs[0].push_str(&format!(", onUpdate: {}", fk.on_update.to_prax()));
1177    }
1178
1179    attrs[0].push(')');
1180
1181    format!("    {} {} {}\n", field_name, ref_name, attrs.join(" "))
1182}
1183
1184fn generate_model_attributes(table: &TableInfo) -> String {
1185    let mut output = String::new();
1186
1187    // @@map if table name differs from model name
1188    let model_name = pascal_case(&table.name);
1189    if model_name.to_lowercase() != table.name.to_lowercase() {
1190        output.push_str(&format!(
1191            "    @@map(\"{}\")\n",
1192            escape_map_value(&table.name)
1193        ));
1194    }
1195
1196    // Composite primary key
1197    if table.primary_key.len() > 1 {
1198        let fields: Vec<_> = table.primary_key.iter().map(|c| camel_case(c)).collect();
1199        output.push_str(&format!("    @@id([{}])\n", fields.join(", ")));
1200    }
1201
1202    // Indexes
1203    for idx in &table.indexes {
1204        if !idx.is_primary {
1205            let cols: Vec<_> = idx.columns.iter().map(|c| camel_case(&c.name)).collect();
1206            if idx.is_unique {
1207                output.push_str(&format!("    @@unique([{}])\n", cols.join(", ")));
1208            } else {
1209                output.push_str(&format!("    @@index([{}])\n", cols.join(", ")));
1210            }
1211        }
1212    }
1213
1214    output
1215}
1216
1217fn generate_view(view: &ViewInfo) -> String {
1218    let mut output = String::new();
1219
1220    let keyword = if view.is_materialized {
1221        "materializedView"
1222    } else {
1223        "view"
1224    };
1225    output.push_str(&format!("{} {} {{\n", keyword, pascal_case(&view.name)));
1226
1227    for col in &view.columns {
1228        let type_str = col.normalized_type.to_prax_type();
1229        let optional = if col.nullable { "?" } else { "" };
1230        output.push_str(&format!(
1231            "    {} {}{}\n",
1232            camel_case(&col.name),
1233            type_str,
1234            optional
1235        ));
1236    }
1237
1238    if let Some(ref def) = view.definition {
1239        output.push_str(&format!("\n    @@sql(\"{}\")\n", escape_map_value(def)));
1240    }
1241
1242    output.push_str("}\n");
1243    output
1244}
1245
1246// ============================================================================
1247// MongoDB Introspection
1248// ============================================================================
1249
1250/// MongoDB collection introspection.
1251pub mod mongodb {
1252    use serde_json::Value as JsonValue;
1253
1254    use super::{ColumnInfo, NormalizedType, TableInfo};
1255
1256    /// Infer schema from MongoDB documents.
1257    #[derive(Debug, Clone, Default)]
1258    pub struct SchemaInferrer {
1259        /// Field types discovered.
1260        pub fields: std::collections::HashMap<String, FieldSchema>,
1261        /// Sample size.
1262        pub samples: usize,
1263    }
1264
1265    /// Inferred field schema.
1266    #[derive(Debug, Clone, Default)]
1267    pub struct FieldSchema {
1268        /// Field name.
1269        pub name: String,
1270        /// Types observed.
1271        pub types: Vec<String>,
1272        /// Whether field is always present.
1273        pub required: bool,
1274        /// Nested fields (for objects).
1275        pub nested: Option<Box<SchemaInferrer>>,
1276        /// Array element type.
1277        pub array_type: Option<String>,
1278    }
1279
1280    impl SchemaInferrer {
1281        /// Create a new inferrer.
1282        pub fn new() -> Self {
1283            Self::default()
1284        }
1285
1286        /// Add a document sample.
1287        pub fn add_document(&mut self, doc: &JsonValue) {
1288            self.samples += 1;
1289
1290            if let Some(obj) = doc.as_object() {
1291                for (key, value) in obj {
1292                    self.infer_field(key, value);
1293                }
1294            }
1295        }
1296
1297        fn infer_field(&mut self, name: &str, value: &JsonValue) {
1298            let field = self
1299                .fields
1300                .entry(name.to_string())
1301                .or_insert_with(|| FieldSchema {
1302                    name: name.to_string(),
1303                    required: true,
1304                    ..Default::default()
1305                });
1306
1307            let type_name = match value {
1308                JsonValue::Null => "null",
1309                JsonValue::Bool(_) => "boolean",
1310                JsonValue::Number(n) if n.is_i64() => "int",
1311                JsonValue::Number(n) if n.is_f64() => "double",
1312                JsonValue::Number(_) => "number",
1313                JsonValue::String(s) => {
1314                    // Try to detect special types
1315                    if s.len() == 24 && s.chars().all(|c| c.is_ascii_hexdigit()) {
1316                        "objectId"
1317                    } else if is_iso_datetime(s) {
1318                        "date"
1319                    } else {
1320                        "string"
1321                    }
1322                }
1323                JsonValue::Array(arr) => {
1324                    if let Some(first) = arr.first() {
1325                        let elem_type = match first {
1326                            JsonValue::Object(_) => "object",
1327                            JsonValue::String(_) => "string",
1328                            JsonValue::Number(_) => "number",
1329                            JsonValue::Bool(_) => "boolean",
1330                            _ => "mixed",
1331                        };
1332                        field.array_type = Some(elem_type.to_string());
1333                    }
1334                    "array"
1335                }
1336                JsonValue::Object(_) => {
1337                    // Recurse for nested objects
1338                    let mut nested = field.nested.take().unwrap_or_default();
1339                    nested.add_document(value);
1340                    field.nested = Some(nested);
1341                    "object"
1342                }
1343            };
1344
1345            if !field.types.contains(&type_name.to_string()) {
1346                field.types.push(type_name.to_string());
1347            }
1348        }
1349
1350        /// Convert to TableInfo.
1351        pub fn to_table_info(&self, collection_name: &str) -> TableInfo {
1352            let mut columns = Vec::new();
1353
1354            for (name, field) in &self.fields {
1355                let normalized = infer_normalized_type(field);
1356                columns.push(ColumnInfo {
1357                    name: name.clone(),
1358                    db_type: field.types.join("|"),
1359                    normalized_type: normalized,
1360                    nullable: !field.required || field.types.contains(&"null".to_string()),
1361                    is_primary_key: name == "_id",
1362                    ..Default::default()
1363                });
1364            }
1365
1366            TableInfo {
1367                name: collection_name.to_string(),
1368                columns,
1369                primary_key: vec!["_id".to_string()],
1370                ..Default::default()
1371            }
1372        }
1373    }
1374
1375    fn infer_normalized_type(field: &FieldSchema) -> NormalizedType {
1376        // Pick most specific type
1377        if field.types.contains(&"objectId".to_string()) {
1378            NormalizedType::String // ObjectId maps to String
1379        } else if field.types.contains(&"date".to_string()) {
1380            NormalizedType::DateTime
1381        } else if field.types.contains(&"boolean".to_string()) {
1382            NormalizedType::Boolean
1383        } else if field.types.contains(&"int".to_string()) {
1384            NormalizedType::Int
1385        } else if field.types.contains(&"double".to_string())
1386            || field.types.contains(&"number".to_string())
1387        {
1388            NormalizedType::Double
1389        } else if field.types.contains(&"array".to_string()) {
1390            let inner = match field.array_type.as_deref() {
1391                Some("string") => NormalizedType::String,
1392                Some("number") => NormalizedType::Double,
1393                Some("boolean") => NormalizedType::Boolean,
1394                _ => NormalizedType::Json,
1395            };
1396            NormalizedType::Array(Box::new(inner))
1397        } else if field.types.contains(&"object".to_string()) {
1398            NormalizedType::Json
1399        } else if field.types.contains(&"string".to_string()) {
1400            NormalizedType::String
1401        } else {
1402            NormalizedType::Unknown(field.types.join("|"))
1403        }
1404    }
1405
1406    /// Generate MongoDB collection indexes command.
1407    pub fn list_indexes_command(collection: &str) -> JsonValue {
1408        serde_json::json!({
1409            "listIndexes": collection
1410        })
1411    }
1412
1413    /// Generate MongoDB list collections command.
1414    pub fn list_collections_command() -> JsonValue {
1415        serde_json::json!({
1416            "listCollections": 1
1417        })
1418    }
1419
1420    /// Simple ISO datetime detection without chrono dependency.
1421    fn is_iso_datetime(s: &str) -> bool {
1422        // Check for ISO 8601 format: YYYY-MM-DDTHH:MM:SS or similar
1423        if s.len() < 10 {
1424            return false;
1425        }
1426
1427        let bytes = s.as_bytes();
1428        // Check YYYY-MM-DD pattern
1429        bytes.get(4) == Some(&b'-')
1430            && bytes.get(7) == Some(&b'-')
1431            && bytes[0..4].iter().all(|b| b.is_ascii_digit())
1432            && bytes[5..7].iter().all(|b| b.is_ascii_digit())
1433            && bytes[8..10].iter().all(|b| b.is_ascii_digit())
1434    }
1435}
1436
1437// ============================================================================
1438// Helpers
1439// ============================================================================
1440
1441/// Convert `snake_case` (or any `_`-delimited name) to `PascalCase`.
1442///
1443/// Idempotent on an input that's already `PascalCase` with no underscores
1444/// (capitalizing an already-uppercase first character is a no-op), so
1445/// callers that pre-derive a `PascalCase` name (e.g. the MySQL introspector
1446/// disambiguating a synthesized enum name against this same transform) can
1447/// still round-trip it through here safely.
1448pub fn pascal_case(s: &str) -> String {
1449    s.split('_')
1450        .map(|part| {
1451            let mut chars = part.chars();
1452            match chars.next() {
1453                None => String::new(),
1454                Some(c) => c.to_uppercase().chain(chars).collect(),
1455            }
1456        })
1457        .collect()
1458}
1459
1460fn camel_case(s: &str) -> String {
1461    let pascal = pascal_case(s);
1462    let mut chars = pascal.chars();
1463    match chars.next() {
1464        None => String::new(),
1465        Some(c) => c.to_lowercase().chain(chars).collect(),
1466    }
1467}
1468
1469fn simplify_default(default: &str) -> String {
1470    // Simplify common default expressions
1471    let d = default.trim();
1472
1473    if d.eq_ignore_ascii_case("now()") || d.eq_ignore_ascii_case("current_timestamp") {
1474        return "now()".to_string();
1475    }
1476
1477    if d.starts_with("'") && d.ends_with("'") {
1478        return format!("\"{}\"", escape_map_value(&d[1..d.len() - 1]));
1479    }
1480
1481    if d.eq_ignore_ascii_case("true") || d.eq_ignore_ascii_case("false") {
1482        return d.to_lowercase();
1483    }
1484
1485    if d.parse::<i64>().is_ok() || d.parse::<f64>().is_ok() {
1486        return d.to_string();
1487    }
1488
1489    format!("dbgenerated(\"{}\")", escape_map_value(d))
1490}
1491
1492#[cfg(test)]
1493mod tests {
1494    use super::*;
1495
1496    #[test]
1497    fn test_pascal_case() {
1498        assert_eq!(pascal_case("user_profile"), "UserProfile");
1499        assert_eq!(pascal_case("id"), "Id");
1500        assert_eq!(pascal_case("created_at"), "CreatedAt");
1501    }
1502
1503    #[test]
1504    fn test_camel_case() {
1505        assert_eq!(camel_case("user_profile"), "userProfile");
1506        assert_eq!(camel_case("ID"), "iD");
1507        assert_eq!(camel_case("created_at"), "createdAt");
1508    }
1509
1510    #[test]
1511    fn test_normalize_postgres_type() {
1512        assert_eq!(
1513            normalize_postgres_type("int4", None, None, None),
1514            NormalizedType::Int
1515        );
1516        assert_eq!(
1517            normalize_postgres_type("bigint", None, None, None),
1518            NormalizedType::BigInt
1519        );
1520        assert_eq!(
1521            normalize_postgres_type("text", None, None, None),
1522            NormalizedType::Text
1523        );
1524        assert_eq!(
1525            normalize_postgres_type("timestamptz", None, None, None),
1526            NormalizedType::DateTime
1527        );
1528        assert_eq!(
1529            normalize_postgres_type("jsonb", None, None, None),
1530            NormalizedType::Json
1531        );
1532        assert_eq!(
1533            normalize_postgres_type("uuid", None, None, None),
1534            NormalizedType::Uuid
1535        );
1536    }
1537
1538    #[test]
1539    fn test_normalize_mysql_type() {
1540        assert_eq!(
1541            normalize_mysql_type("int", None, None, None),
1542            NormalizedType::Int
1543        );
1544        assert_eq!(
1545            normalize_mysql_type("varchar", Some(255), None, None),
1546            NormalizedType::VarChar { length: Some(255) }
1547        );
1548        assert_eq!(
1549            normalize_mysql_type("datetime", None, None, None),
1550            NormalizedType::DateTime
1551        );
1552    }
1553
1554    #[test]
1555    fn test_parse_mysql_enum_values() {
1556        assert_eq!(
1557            parse_mysql_enum_values("enum('active','inactive')"),
1558            vec!["active".to_string(), "inactive".to_string()]
1559        );
1560        assert_eq!(
1561            parse_mysql_enum_values("enum('it''s ok','plain')"),
1562            vec!["it's ok".to_string(), "plain".to_string()]
1563        );
1564        assert_eq!(
1565            parse_mysql_enum_values("enum('solo')"),
1566            vec!["solo".to_string()]
1567        );
1568        // Values may contain a literal comma; the parser tracks quotes
1569        // rather than splitting on ',', so this must not be split in two.
1570        assert_eq!(
1571            parse_mysql_enum_values("enum('a,b','c')"),
1572            vec!["a,b".to_string(), "c".to_string()]
1573        );
1574        // COLUMN_TYPE's `enum` keyword is matched case-insensitively.
1575        assert_eq!(
1576            parse_mysql_enum_values("ENUM('active')"),
1577            vec!["active".to_string()]
1578        );
1579        // Must not panic on non-ASCII input whose byte length happens to be
1580        // >= 5 but has no char boundary at byte offset 5.
1581        assert_eq!(parse_mysql_enum_values("日本語"), Vec::<String>::new());
1582        assert_eq!(parse_mysql_enum_values("日本"), Vec::<String>::new());
1583        assert_eq!(parse_mysql_enum_values(""), Vec::<String>::new());
1584    }
1585
1586    #[test]
1587    fn test_sanitize_identifier() {
1588        assert_eq!(sanitize_identifier("active"), "active");
1589        assert_eq!(sanitize_identifier("in-progress"), "in_progress");
1590        assert_eq!(sanitize_identifier("1"), "V1");
1591        assert_eq!(sanitize_identifier(""), "V");
1592    }
1593
1594    #[test]
1595    fn test_escape_map_value() {
1596        assert_eq!(escape_map_value("plain"), "plain");
1597        // Lossless backslash escapes — the `.prax` grammar supports `\"`
1598        // and `\\`, so the value must survive a generate→parse round-trip
1599        // instead of being silently substituted.
1600        assert_eq!(escape_map_value("say \"hi\""), "say \\\"hi\\\"");
1601        assert_eq!(escape_map_value("a\\b"), "a\\\\b");
1602    }
1603
1604    #[test]
1605    fn test_sanitize_variants_disambiguates_collisions() {
1606        let raw = vec![
1607            "in-progress".to_string(),
1608            "in_progress".to_string(),
1609            "done".to_string(),
1610        ];
1611        assert_eq!(
1612            sanitize_variants(&raw),
1613            vec![
1614                "in_progress".to_string(),
1615                "in_progress_2".to_string(),
1616                "done".to_string(),
1617            ]
1618        );
1619    }
1620
1621    #[test]
1622    fn test_enum_to_prax_type_matches_generate_enum_declaration() {
1623        let enum_info = EnumInfo {
1624            name: "users_status".to_string(),
1625            schema: None,
1626            values: vec!["active".to_string()],
1627        };
1628        let declared = generate_enum(&enum_info);
1629        assert!(declared.starts_with("enum UsersStatus {"));
1630        // `@@map` pins the real DB type name so a diff/migration targets
1631        // `users_status`, not the PascalCased `UsersStatus` (which doesn't
1632        // exist in the live database).
1633        assert!(declared.contains("@@map(\"users_status\")"));
1634        assert_eq!(
1635            NormalizedType::Enum("users_status".to_string()).to_prax_type(),
1636            "UsersStatus"
1637        );
1638    }
1639
1640    #[test]
1641    fn test_generate_enum_pins_sanitized_variant_values_with_map() {
1642        let enum_info = EnumInfo {
1643            name: "task_status".to_string(),
1644            schema: None,
1645            values: vec!["in-progress".to_string(), "done".to_string()],
1646        };
1647        let declared = generate_enum(&enum_info);
1648        // A value that needed sanitizing gets `@map` with the real value...
1649        assert!(declared.contains("in_progress @map(\"in-progress\")"));
1650        // ...one that didn't need it (already a legal identifier) doesn't.
1651        assert!(declared.contains("    done\n"));
1652        assert!(!declared.contains("done @map"));
1653    }
1654
1655    #[test]
1656    fn test_generate_enum_escapes_embedded_quotes_in_map_value() {
1657        // The `.prax` grammar supports `\"`/`\\` escapes, so a MySQL enum
1658        // value containing `"` must be escaped — never substituted — or
1659        // the written file parses back to a different value. Must emit a
1660        // parseable file.
1661        let enum_info = EnumInfo {
1662            name: "task_status".to_string(),
1663            schema: None,
1664            values: vec!["say \"hi\"".to_string(), "a\\b".to_string()],
1665        };
1666        let declared = generate_enum(&enum_info);
1667        assert!(declared.contains("@map(\"say \\\"hi\\\"\")"));
1668        assert!(declared.contains("@map(\"a\\\\b\")"));
1669    }
1670
1671    #[test]
1672    fn test_referential_action() {
1673        assert_eq!(
1674            ReferentialAction::from_str("CASCADE"),
1675            ReferentialAction::Cascade
1676        );
1677        assert_eq!(
1678            ReferentialAction::from_str("SET NULL"),
1679            ReferentialAction::SetNull
1680        );
1681        assert_eq!(
1682            ReferentialAction::from_str("NO ACTION"),
1683            ReferentialAction::NoAction
1684        );
1685    }
1686
1687    #[test]
1688    fn test_generate_simple_model() {
1689        let table = TableInfo {
1690            name: "users".to_string(),
1691            columns: vec![
1692                ColumnInfo {
1693                    name: "id".to_string(),
1694                    normalized_type: NormalizedType::Int,
1695                    auto_increment: true,
1696                    ..Default::default()
1697                },
1698                ColumnInfo {
1699                    name: "email".to_string(),
1700                    normalized_type: NormalizedType::String,
1701                    is_unique: true,
1702                    ..Default::default()
1703                },
1704                ColumnInfo {
1705                    name: "created_at".to_string(),
1706                    normalized_type: NormalizedType::DateTime,
1707                    nullable: true,
1708                    default: Some("now()".to_string()),
1709                    ..Default::default()
1710                },
1711            ],
1712            primary_key: vec!["id".to_string()],
1713            ..Default::default()
1714        };
1715
1716        let schema = generate_model(&table, &[]);
1717        assert!(schema.contains("model Users"));
1718        assert!(schema.contains("id Int @id @auto"));
1719        assert!(schema.contains("email String @unique"));
1720        assert!(schema.contains("createdAt DateTime?"));
1721    }
1722
1723    #[test]
1724    fn test_simplify_default() {
1725        assert_eq!(simplify_default("NOW()"), "now()");
1726        assert_eq!(simplify_default("CURRENT_TIMESTAMP"), "now()");
1727        assert_eq!(simplify_default("'hello'"), "\"hello\"");
1728        // String defaults are `.prax` string literals: embedded quotes and
1729        // backslashes must be escaped, not emitted raw.
1730        assert_eq!(simplify_default("'say \"hi\"'"), "\"say \\\"hi\\\"\"");
1731        assert_eq!(simplify_default("42"), "42");
1732        assert_eq!(simplify_default("true"), "true");
1733    }
1734
1735    #[test]
1736    fn test_queries_tables() {
1737        let pg = queries::tables_query(DatabaseType::PostgreSQL, Some("public"));
1738        assert!(pg.contains("information_schema.tables"));
1739        assert!(pg.contains("public"));
1740
1741        let mysql = queries::tables_query(DatabaseType::MySQL, None);
1742        assert!(mysql.contains("information_schema.tables"));
1743
1744        let sqlite = queries::tables_query(DatabaseType::SQLite, None);
1745        assert!(sqlite.contains("sqlite_master"));
1746    }
1747
1748    #[test]
1749    fn test_escape_literal() {
1750        assert_eq!(escape_literal("public"), "public");
1751        assert_eq!(escape_literal("o'brien"), "o''brien");
1752        assert_eq!(
1753            escape_literal("'; DROP TABLE users; --"),
1754            "''; DROP TABLE users; --"
1755        );
1756    }
1757
1758    #[test]
1759    fn test_queries_escape_interpolated_names() {
1760        // SQLite PRAGMA takes the table name as a string literal.
1761        let sql = queries::columns_query(DatabaseType::SQLite, "we'ird", None);
1762        assert!(sql.contains("PRAGMA table_info('we''ird')"), "got: {sql}");
1763
1764        // MySQL / PostgreSQL schema filters.
1765        let sql = queries::tables_query(DatabaseType::MySQL, Some("my'schema"));
1766        assert!(
1767            sql.contains("AND table_schema = 'my''schema'"),
1768            "got: {sql}"
1769        );
1770        let sql = queries::tables_query(DatabaseType::PostgreSQL, Some("my'schema"));
1771        assert!(sql.contains("table_schema = 'my''schema'"), "got: {sql}");
1772    }
1773
1774    mod mongodb_tests {
1775        use super::super::mongodb::*;
1776
1777        #[test]
1778        fn test_schema_inferrer() {
1779            let mut inferrer = SchemaInferrer::new();
1780
1781            inferrer.add_document(&serde_json::json!({
1782                "_id": "507f1f77bcf86cd799439011",
1783                "name": "Alice",
1784                "age": 30,
1785                "active": true
1786            }));
1787
1788            inferrer.add_document(&serde_json::json!({
1789                "_id": "507f1f77bcf86cd799439012",
1790                "name": "Bob",
1791                "age": 25,
1792                "active": false,
1793                "email": "bob@example.com"
1794            }));
1795
1796            let table = inferrer.to_table_info("users");
1797            assert_eq!(table.name, "users");
1798            assert!(table.columns.iter().any(|c| c.name == "_id"));
1799            assert!(table.columns.iter().any(|c| c.name == "name"));
1800            assert!(table.columns.iter().any(|c| c.name == "age"));
1801        }
1802    }
1803}