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;
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            Self::Enum(name) => name.clone(),
172            Self::Unknown(t) => format!("Unsupported<{}>", t),
173        }
174    }
175}
176
177/// Information about a foreign key.
178#[derive(Debug, Clone, Default, Serialize, Deserialize)]
179pub struct ForeignKeyInfo {
180    /// Constraint name.
181    pub name: String,
182    /// Local columns.
183    pub columns: Vec<String>,
184    /// Referenced table.
185    pub referenced_table: String,
186    /// Referenced schema.
187    pub referenced_schema: Option<String>,
188    /// Referenced columns.
189    pub referenced_columns: Vec<String>,
190    /// ON DELETE action.
191    pub on_delete: ReferentialAction,
192    /// ON UPDATE action.
193    pub on_update: ReferentialAction,
194}
195
196/// Referential action for foreign keys.
197#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
198pub enum ReferentialAction {
199    #[default]
200    NoAction,
201    Restrict,
202    Cascade,
203    SetNull,
204    SetDefault,
205}
206
207impl ReferentialAction {
208    /// Convert to Prax schema string.
209    pub fn to_prax(&self) -> &'static str {
210        match self {
211            Self::NoAction => "NoAction",
212            Self::Restrict => "Restrict",
213            Self::Cascade => "Cascade",
214            Self::SetNull => "SetNull",
215            Self::SetDefault => "SetDefault",
216        }
217    }
218
219    /// Parse from database string.
220    pub fn from_str(s: &str) -> Self {
221        match s.to_uppercase().as_str() {
222            "NO ACTION" | "NOACTION" => Self::NoAction,
223            "RESTRICT" => Self::Restrict,
224            "CASCADE" => Self::Cascade,
225            "SET NULL" | "SETNULL" => Self::SetNull,
226            "SET DEFAULT" | "SETDEFAULT" => Self::SetDefault,
227            _ => Self::NoAction,
228        }
229    }
230}
231
232/// Information about an index.
233#[derive(Debug, Clone, Default, Serialize, Deserialize)]
234pub struct IndexInfo {
235    /// Index name.
236    pub name: String,
237    /// Columns in the index.
238    pub columns: Vec<IndexColumn>,
239    /// Whether this is a unique index.
240    pub is_unique: bool,
241    /// Whether this is a primary key index.
242    pub is_primary: bool,
243    /// Index type (btree, hash, gin, etc.).
244    pub index_type: Option<String>,
245    /// Filter condition (partial index).
246    pub filter: Option<String>,
247}
248
249/// A column in an index.
250#[derive(Debug, Clone, Default, Serialize, Deserialize)]
251pub struct IndexColumn {
252    /// Column name.
253    pub name: String,
254    /// Sort order.
255    pub order: SortOrder,
256    /// Nulls position.
257    pub nulls: NullsOrder,
258}
259
260/// Sort order for index columns.
261#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
262pub enum SortOrder {
263    #[default]
264    Asc,
265    Desc,
266}
267
268/// Nulls ordering for index columns.
269#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
270pub enum NullsOrder {
271    #[default]
272    Last,
273    First,
274}
275
276/// Unique constraint information.
277#[derive(Debug, Clone, Default, Serialize, Deserialize)]
278pub struct UniqueConstraint {
279    /// Constraint name.
280    pub name: String,
281    /// Columns.
282    pub columns: Vec<String>,
283}
284
285/// Check constraint information.
286#[derive(Debug, Clone, Default, Serialize, Deserialize)]
287pub struct CheckConstraint {
288    /// Constraint name.
289    pub name: String,
290    /// Check expression.
291    pub expression: String,
292}
293
294/// Information about a view.
295#[derive(Debug, Clone, Default, Serialize, Deserialize)]
296pub struct ViewInfo {
297    /// View name.
298    pub name: String,
299    /// Schema.
300    pub schema: Option<String>,
301    /// View definition SQL.
302    pub definition: Option<String>,
303    /// Whether this is a materialized view.
304    pub is_materialized: bool,
305    /// Columns (inferred from definition).
306    pub columns: Vec<ColumnInfo>,
307}
308
309/// Information about an enum type.
310#[derive(Debug, Clone, Default, Serialize, Deserialize)]
311pub struct EnumInfo {
312    /// Enum type name.
313    pub name: String,
314    /// Schema.
315    pub schema: Option<String>,
316    /// Enum values.
317    pub values: Vec<String>,
318}
319
320/// Information about a sequence.
321#[derive(Debug, Clone, Default, Serialize, Deserialize)]
322pub struct SequenceInfo {
323    /// Sequence name.
324    pub name: String,
325    /// Schema.
326    pub schema: Option<String>,
327    /// Start value.
328    pub start: i64,
329    /// Increment.
330    pub increment: i64,
331    /// Minimum value.
332    pub min_value: Option<i64>,
333    /// Maximum value.
334    pub max_value: Option<i64>,
335    /// Whether it cycles.
336    pub cycle: bool,
337}
338
339// ============================================================================
340// Introspection Queries
341// ============================================================================
342
343/// SQL queries for database introspection.
344pub mod queries {
345    use super::*;
346
347    /// Get tables query.
348    pub fn tables_query(db_type: DatabaseType, schema: Option<&str>) -> String {
349        match db_type {
350            DatabaseType::PostgreSQL => {
351                let schema_filter = schema.unwrap_or("public");
352                format!(
353                    "SELECT table_name, obj_description((quote_ident(table_schema) || '.' || quote_ident(table_name))::regclass) as comment \
354                     FROM information_schema.tables \
355                     WHERE table_schema = '{}' AND table_type = 'BASE TABLE' \
356                     ORDER BY table_name",
357                    schema_filter
358                )
359            }
360            DatabaseType::MySQL => {
361                let schema_filter = schema
362                    .map(|s| format!("AND table_schema = '{}'", s))
363                    .unwrap_or_default();
364                format!(
365                    "SELECT table_name, table_comment as comment \
366                     FROM information_schema.tables \
367                     WHERE table_type = 'BASE TABLE' {} \
368                     ORDER BY table_name",
369                    schema_filter
370                )
371            }
372            DatabaseType::SQLite => "SELECT name as table_name, NULL as comment \
373                 FROM sqlite_master \
374                 WHERE type = 'table' AND name NOT LIKE 'sqlite_%' \
375                 ORDER BY name"
376                .to_string(),
377            DatabaseType::MSSQL => {
378                let schema_filter = schema.unwrap_or("dbo");
379                format!(
380                    "SELECT t.name as table_name, ep.value as comment \
381                     FROM sys.tables t \
382                     LEFT JOIN sys.extended_properties ep ON ep.major_id = t.object_id AND ep.minor_id = 0 AND ep.name = 'MS_Description' \
383                     JOIN sys.schemas s ON t.schema_id = s.schema_id \
384                     WHERE s.name = '{}' \
385                     ORDER BY t.name",
386                    schema_filter
387                )
388            }
389        }
390    }
391
392    /// Get columns query.
393    pub fn columns_query(db_type: DatabaseType, table: &str, schema: Option<&str>) -> String {
394        match db_type {
395            DatabaseType::PostgreSQL => {
396                let schema_filter = schema.unwrap_or("public");
397                format!(
398                    "SELECT \
399                        c.column_name, \
400                        c.data_type, \
401                        c.udt_name, \
402                        c.is_nullable = 'YES' as nullable, \
403                        c.column_default, \
404                        c.character_maximum_length, \
405                        c.numeric_precision, \
406                        c.numeric_scale, \
407                        col_description((quote_ident(c.table_schema) || '.' || quote_ident(c.table_name))::regclass, c.ordinal_position) as comment, \
408                        CASE WHEN c.column_default LIKE 'nextval%' THEN true ELSE false END as auto_increment \
409                     FROM information_schema.columns c \
410                     WHERE c.table_schema = '{}' AND c.table_name = '{}' \
411                     ORDER BY c.ordinal_position",
412                    schema_filter, table
413                )
414            }
415            DatabaseType::MySQL => {
416                format!(
417                    "SELECT \
418                        column_name, \
419                        data_type, \
420                        column_type as udt_name, \
421                        is_nullable = 'YES' as nullable, \
422                        column_default, \
423                        character_maximum_length, \
424                        numeric_precision, \
425                        numeric_scale, \
426                        column_comment as comment, \
427                        extra LIKE '%auto_increment%' as auto_increment \
428                     FROM information_schema.columns \
429                     WHERE table_name = '{}' {} \
430                     ORDER BY ordinal_position",
431                    table,
432                    schema
433                        .map(|s| format!("AND table_schema = '{}'", s))
434                        .unwrap_or_default()
435                )
436            }
437            DatabaseType::SQLite => {
438                format!("PRAGMA table_info('{}')", table)
439            }
440            DatabaseType::MSSQL => {
441                let schema_filter = schema.unwrap_or("dbo");
442                format!(
443                    "SELECT \
444                        c.name as column_name, \
445                        t.name as data_type, \
446                        t.name as udt_name, \
447                        c.is_nullable as nullable, \
448                        dc.definition as column_default, \
449                        c.max_length as character_maximum_length, \
450                        c.precision as numeric_precision, \
451                        c.scale as numeric_scale, \
452                        ep.value as comment, \
453                        c.is_identity as auto_increment \
454                     FROM sys.columns c \
455                     JOIN sys.types t ON c.user_type_id = t.user_type_id \
456                     JOIN sys.tables tb ON c.object_id = tb.object_id \
457                     JOIN sys.schemas s ON tb.schema_id = s.schema_id \
458                     LEFT JOIN sys.default_constraints dc ON c.default_object_id = dc.object_id \
459                     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' \
460                     WHERE tb.name = '{}' AND s.name = '{}' \
461                     ORDER BY c.column_id",
462                    table, schema_filter
463                )
464            }
465        }
466    }
467
468    /// Get primary keys query.
469    pub fn primary_keys_query(db_type: DatabaseType, table: &str, schema: Option<&str>) -> String {
470        match db_type {
471            DatabaseType::PostgreSQL => {
472                let schema_filter = schema.unwrap_or("public");
473                format!(
474                    "SELECT a.attname as column_name \
475                     FROM pg_index i \
476                     JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey) \
477                     JOIN pg_class c ON c.oid = i.indrelid \
478                     JOIN pg_namespace n ON n.oid = c.relnamespace \
479                     WHERE i.indisprimary AND c.relname = '{}' AND n.nspname = '{}' \
480                     ORDER BY array_position(i.indkey, a.attnum)",
481                    table, schema_filter
482                )
483            }
484            DatabaseType::MySQL => {
485                format!(
486                    "SELECT column_name \
487                     FROM information_schema.key_column_usage \
488                     WHERE constraint_name = 'PRIMARY' AND table_name = '{}' {} \
489                     ORDER BY ordinal_position",
490                    table,
491                    schema
492                        .map(|s| format!("AND table_schema = '{}'", s))
493                        .unwrap_or_default()
494                )
495            }
496            DatabaseType::SQLite => {
497                format!("PRAGMA table_info('{}')", table) // Filter pk column in result
498            }
499            DatabaseType::MSSQL => {
500                let schema_filter = schema.unwrap_or("dbo");
501                format!(
502                    "SELECT c.name as column_name \
503                     FROM sys.indexes i \
504                     JOIN sys.index_columns ic ON i.object_id = ic.object_id AND i.index_id = ic.index_id \
505                     JOIN sys.columns c ON ic.object_id = c.object_id AND ic.column_id = c.column_id \
506                     JOIN sys.tables t ON i.object_id = t.object_id \
507                     JOIN sys.schemas s ON t.schema_id = s.schema_id \
508                     WHERE i.is_primary_key = 1 AND t.name = '{}' AND s.name = '{}' \
509                     ORDER BY ic.key_ordinal",
510                    table, schema_filter
511                )
512            }
513        }
514    }
515
516    /// Get foreign keys query.
517    pub fn foreign_keys_query(db_type: DatabaseType, table: &str, schema: Option<&str>) -> String {
518        match db_type {
519            DatabaseType::PostgreSQL => {
520                let schema_filter = schema.unwrap_or("public");
521                format!(
522                    "SELECT \
523                        tc.constraint_name, \
524                        kcu.column_name, \
525                        ccu.table_name as referenced_table, \
526                        ccu.table_schema as referenced_schema, \
527                        ccu.column_name as referenced_column, \
528                        rc.delete_rule, \
529                        rc.update_rule \
530                     FROM information_schema.table_constraints tc \
531                     JOIN information_schema.key_column_usage kcu ON tc.constraint_name = kcu.constraint_name \
532                     JOIN information_schema.constraint_column_usage ccu ON ccu.constraint_name = tc.constraint_name \
533                     JOIN information_schema.referential_constraints rc ON rc.constraint_name = tc.constraint_name \
534                     WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_name = '{}' AND tc.table_schema = '{}' \
535                     ORDER BY tc.constraint_name, kcu.ordinal_position",
536                    table, schema_filter
537                )
538            }
539            DatabaseType::MySQL => {
540                format!(
541                    "SELECT \
542                        constraint_name, \
543                        column_name, \
544                        referenced_table_name as referenced_table, \
545                        referenced_table_schema as referenced_schema, \
546                        referenced_column_name as referenced_column, \
547                        'NO ACTION' as delete_rule, \
548                        'NO ACTION' as update_rule \
549                     FROM information_schema.key_column_usage \
550                     WHERE referenced_table_name IS NOT NULL AND table_name = '{}' {} \
551                     ORDER BY constraint_name, ordinal_position",
552                    table,
553                    schema
554                        .map(|s| format!("AND table_schema = '{}'", s))
555                        .unwrap_or_default()
556                )
557            }
558            DatabaseType::SQLite => {
559                format!("PRAGMA foreign_key_list('{}')", table)
560            }
561            DatabaseType::MSSQL => {
562                let schema_filter = schema.unwrap_or("dbo");
563                format!(
564                    "SELECT \
565                        fk.name as constraint_name, \
566                        c.name as column_name, \
567                        rt.name as referenced_table, \
568                        rs.name as referenced_schema, \
569                        rc.name as referenced_column, \
570                        fk.delete_referential_action_desc as delete_rule, \
571                        fk.update_referential_action_desc as update_rule \
572                     FROM sys.foreign_keys fk \
573                     JOIN sys.foreign_key_columns fkc ON fk.object_id = fkc.constraint_object_id \
574                     JOIN sys.columns c ON fkc.parent_object_id = c.object_id AND fkc.parent_column_id = c.column_id \
575                     JOIN sys.tables t ON fk.parent_object_id = t.object_id \
576                     JOIN sys.schemas s ON t.schema_id = s.schema_id \
577                     JOIN sys.tables rt ON fk.referenced_object_id = rt.object_id \
578                     JOIN sys.schemas rs ON rt.schema_id = rs.schema_id \
579                     JOIN sys.columns rc ON fkc.referenced_object_id = rc.object_id AND fkc.referenced_column_id = rc.column_id \
580                     WHERE t.name = '{}' AND s.name = '{}' \
581                     ORDER BY fk.name",
582                    table, schema_filter
583                )
584            }
585        }
586    }
587
588    /// Get indexes query.
589    pub fn indexes_query(db_type: DatabaseType, table: &str, schema: Option<&str>) -> String {
590        match db_type {
591            DatabaseType::PostgreSQL => {
592                let schema_filter = schema.unwrap_or("public");
593                format!(
594                    "SELECT \
595                        i.relname as index_name, \
596                        a.attname as column_name, \
597                        ix.indisunique as is_unique, \
598                        ix.indisprimary as is_primary, \
599                        am.amname as index_type, \
600                        pg_get_expr(ix.indpred, ix.indrelid) as filter \
601                     FROM pg_index ix \
602                     JOIN pg_class t ON t.oid = ix.indrelid \
603                     JOIN pg_class i ON i.oid = ix.indexrelid \
604                     JOIN pg_namespace n ON n.oid = t.relnamespace \
605                     JOIN pg_am am ON i.relam = am.oid \
606                     JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(ix.indkey) \
607                     WHERE t.relname = '{}' AND n.nspname = '{}' \
608                     ORDER BY i.relname, array_position(ix.indkey, a.attnum)",
609                    table, schema_filter
610                )
611            }
612            DatabaseType::MySQL => {
613                format!(
614                    "SELECT \
615                        index_name, \
616                        column_name, \
617                        NOT non_unique as is_unique, \
618                        index_name = 'PRIMARY' as is_primary, \
619                        index_type, \
620                        NULL as filter \
621                     FROM information_schema.statistics \
622                     WHERE table_name = '{}' {} \
623                     ORDER BY index_name, seq_in_index",
624                    table,
625                    schema
626                        .map(|s| format!("AND table_schema = '{}'", s))
627                        .unwrap_or_default()
628                )
629            }
630            DatabaseType::SQLite => {
631                format!("PRAGMA index_list('{}')", table)
632            }
633            DatabaseType::MSSQL => {
634                let schema_filter = schema.unwrap_or("dbo");
635                format!(
636                    "SELECT \
637                        i.name as index_name, \
638                        c.name as column_name, \
639                        i.is_unique, \
640                        i.is_primary_key as is_primary, \
641                        i.type_desc as index_type, \
642                        i.filter_definition as filter \
643                     FROM sys.indexes i \
644                     JOIN sys.index_columns ic ON i.object_id = ic.object_id AND i.index_id = ic.index_id \
645                     JOIN sys.columns c ON ic.object_id = c.object_id AND ic.column_id = c.column_id \
646                     JOIN sys.tables t ON i.object_id = t.object_id \
647                     JOIN sys.schemas s ON t.schema_id = s.schema_id \
648                     WHERE t.name = '{}' AND s.name = '{}' AND i.name IS NOT NULL \
649                     ORDER BY i.name, ic.key_ordinal",
650                    table, schema_filter
651                )
652            }
653        }
654    }
655
656    /// Get enums query (PostgreSQL only).
657    pub fn enums_query(schema: Option<&str>) -> String {
658        let schema_filter = schema.unwrap_or("public");
659        format!(
660            "SELECT t.typname as enum_name, e.enumlabel as enum_value \
661             FROM pg_type t \
662             JOIN pg_enum e ON t.oid = e.enumtypid \
663             JOIN pg_namespace n ON n.oid = t.typnamespace \
664             WHERE n.nspname = '{}' \
665             ORDER BY t.typname, e.enumsortorder",
666            schema_filter
667        )
668    }
669
670    /// Get views query.
671    pub fn views_query(db_type: DatabaseType, schema: Option<&str>) -> String {
672        match db_type {
673            DatabaseType::PostgreSQL => {
674                let schema_filter = schema.unwrap_or("public");
675                format!(
676                    "SELECT table_name as view_name, view_definition, false as is_materialized \
677                     FROM information_schema.views \
678                     WHERE table_schema = '{}' \
679                     UNION ALL \
680                     SELECT matviewname as view_name, definition as view_definition, true as is_materialized \
681                     FROM pg_matviews \
682                     WHERE schemaname = '{}' \
683                     ORDER BY view_name",
684                    schema_filter, schema_filter
685                )
686            }
687            DatabaseType::MySQL => {
688                format!(
689                    "SELECT table_name as view_name, view_definition, false as is_materialized \
690                     FROM information_schema.views \
691                     WHERE table_schema = '{}' \
692                     ORDER BY view_name",
693                    schema.unwrap_or("information_schema")
694                )
695            }
696            DatabaseType::SQLite => {
697                "SELECT name as view_name, sql as view_definition, 0 as is_materialized \
698                 FROM sqlite_master \
699                 WHERE type = 'view' \
700                 ORDER BY name"
701                    .to_string()
702            }
703            DatabaseType::MSSQL => {
704                let schema_filter = schema.unwrap_or("dbo");
705                format!(
706                    "SELECT v.name as view_name, m.definition as view_definition, \
707                     CASE WHEN i.object_id IS NOT NULL THEN 1 ELSE 0 END as is_materialized \
708                     FROM sys.views v \
709                     JOIN sys.schemas s ON v.schema_id = s.schema_id \
710                     JOIN sys.sql_modules m ON v.object_id = m.object_id \
711                     LEFT JOIN sys.indexes i ON v.object_id = i.object_id AND i.index_id = 1 \
712                     WHERE s.name = '{}' \
713                     ORDER BY v.name",
714                    schema_filter
715                )
716            }
717        }
718    }
719}
720
721// ============================================================================
722// Type Mapping
723// ============================================================================
724
725/// Map database types to normalized types.
726pub fn normalize_type(
727    db_type: DatabaseType,
728    type_name: &str,
729    max_length: Option<i32>,
730    precision: Option<i32>,
731    scale: Option<i32>,
732) -> NormalizedType {
733    let type_lower = type_name.to_lowercase();
734
735    match db_type {
736        DatabaseType::PostgreSQL => {
737            normalize_postgres_type(&type_lower, max_length, precision, scale)
738        }
739        DatabaseType::MySQL => normalize_mysql_type(&type_lower, max_length, precision, scale),
740        DatabaseType::SQLite => normalize_sqlite_type(&type_lower),
741        DatabaseType::MSSQL => normalize_mssql_type(&type_lower, max_length, precision, scale),
742    }
743}
744
745fn normalize_postgres_type(
746    type_name: &str,
747    _max_length: Option<i32>,
748    precision: Option<i32>,
749    scale: Option<i32>,
750) -> NormalizedType {
751    match type_name {
752        "int2" | "smallint" | "smallserial" => NormalizedType::SmallInt,
753        "int4" | "integer" | "int" | "serial" => NormalizedType::Int,
754        "int8" | "bigint" | "bigserial" => NormalizedType::BigInt,
755        "real" | "float4" => NormalizedType::Float,
756        "double precision" | "float8" => NormalizedType::Double,
757        "numeric" | "decimal" => NormalizedType::Decimal { precision, scale },
758        "bool" | "boolean" => NormalizedType::Boolean,
759        "text" => NormalizedType::Text,
760        "varchar" | "character varying" => NormalizedType::VarChar {
761            length: _max_length,
762        },
763        "char" | "character" | "bpchar" => NormalizedType::Char {
764            length: _max_length,
765        },
766        "bytea" => NormalizedType::Bytes,
767        "timestamp" | "timestamp without time zone" => NormalizedType::Timestamp,
768        "timestamptz" | "timestamp with time zone" => NormalizedType::DateTime,
769        "date" => NormalizedType::Date,
770        "time" | "time without time zone" | "timetz" | "time with time zone" => {
771            NormalizedType::Time
772        }
773        "json" | "jsonb" => NormalizedType::Json,
774        "uuid" => NormalizedType::Uuid,
775        t if t.ends_with("[]") => {
776            let inner = normalize_postgres_type(&t[..t.len() - 2], None, None, None);
777            NormalizedType::Array(Box::new(inner))
778        }
779        t => NormalizedType::Unknown(t.to_string()),
780    }
781}
782
783fn normalize_mysql_type(
784    type_name: &str,
785    max_length: Option<i32>,
786    precision: Option<i32>,
787    scale: Option<i32>,
788) -> NormalizedType {
789    match type_name {
790        "tinyint" | "smallint" => NormalizedType::SmallInt,
791        "int" | "integer" | "mediumint" => NormalizedType::Int,
792        "bigint" => NormalizedType::BigInt,
793        "float" => NormalizedType::Float,
794        "double" | "real" => NormalizedType::Double,
795        "decimal" | "numeric" => NormalizedType::Decimal { precision, scale },
796        "bit" | "bool" | "boolean" => NormalizedType::Boolean,
797        "text" | "mediumtext" | "longtext" => NormalizedType::Text,
798        "varchar" => NormalizedType::VarChar { length: max_length },
799        "char" => NormalizedType::Char { length: max_length },
800        "tinyblob" | "blob" | "mediumblob" | "longblob" | "binary" | "varbinary" => {
801            NormalizedType::Bytes
802        }
803        "datetime" | "timestamp" => NormalizedType::DateTime,
804        "date" => NormalizedType::Date,
805        "time" => NormalizedType::Time,
806        "json" => NormalizedType::Json,
807        t if t.starts_with("enum(") => {
808            // Extract enum name from table context
809            NormalizedType::Enum(t.to_string())
810        }
811        t => NormalizedType::Unknown(t.to_string()),
812    }
813}
814
815fn normalize_sqlite_type(type_name: &str) -> NormalizedType {
816    // SQLite has dynamic typing, so we map by affinity
817    match type_name {
818        "integer" | "int" => NormalizedType::Int,
819        "real" | "float" | "double" => NormalizedType::Double,
820        "text" | "varchar" | "char" | "clob" => NormalizedType::Text,
821        "blob" => NormalizedType::Bytes,
822        "boolean" | "bool" => NormalizedType::Boolean,
823        "datetime" | "timestamp" | "date" | "time" => NormalizedType::DateTime,
824        t => NormalizedType::Unknown(t.to_string()),
825    }
826}
827
828fn normalize_mssql_type(
829    type_name: &str,
830    max_length: Option<i32>,
831    precision: Option<i32>,
832    scale: Option<i32>,
833) -> NormalizedType {
834    match type_name {
835        "tinyint" | "smallint" => NormalizedType::SmallInt,
836        "int" => NormalizedType::Int,
837        "bigint" => NormalizedType::BigInt,
838        "real" | "float" => NormalizedType::Float,
839        "decimal" | "numeric" | "money" | "smallmoney" => {
840            NormalizedType::Decimal { precision, scale }
841        }
842        "bit" => NormalizedType::Boolean,
843        "text" | "ntext" => NormalizedType::Text,
844        "varchar" | "nvarchar" => NormalizedType::VarChar { length: max_length },
845        "char" | "nchar" => NormalizedType::Char { length: max_length },
846        "binary" | "varbinary" | "image" => NormalizedType::Bytes,
847        "datetime" | "datetime2" | "datetimeoffset" | "smalldatetime" => NormalizedType::DateTime,
848        "date" => NormalizedType::Date,
849        "time" => NormalizedType::Time,
850        "uniqueidentifier" => NormalizedType::Uuid,
851        t => NormalizedType::Unknown(t.to_string()),
852    }
853}
854
855// ============================================================================
856// Schema Generation
857// ============================================================================
858
859/// Generate Prax schema from introspection result.
860pub fn generate_prax_schema(db: &DatabaseSchema) -> String {
861    let mut output = String::new();
862
863    // Header comment
864    output.push_str("// Generated by Prax introspection\n");
865    output.push_str(&format!("// Database: {}\n\n", db.name));
866
867    // Generate enums
868    for enum_info in &db.enums {
869        output.push_str(&generate_enum(enum_info));
870        output.push('\n');
871    }
872
873    // Generate models
874    for table in &db.tables {
875        output.push_str(&generate_model(table, &db.tables));
876        output.push('\n');
877    }
878
879    // Generate views
880    for view in &db.views {
881        output.push_str(&generate_view(view));
882        output.push('\n');
883    }
884
885    output
886}
887
888fn generate_enum(enum_info: &EnumInfo) -> String {
889    let mut output = format!("enum {} {{\n", enum_info.name);
890    for value in &enum_info.values {
891        output.push_str(&format!("    {}\n", value));
892    }
893    output.push_str("}\n");
894    output
895}
896
897fn generate_model(table: &TableInfo, all_tables: &[TableInfo]) -> String {
898    let mut output = String::new();
899
900    // Comment
901    if let Some(ref comment) = table.comment {
902        output.push_str(&format!("/// {}\n", comment));
903    }
904
905    output.push_str(&format!("model {} {{\n", pascal_case(&table.name)));
906
907    // Fields
908    for col in &table.columns {
909        output.push_str(&generate_field(col, &table.primary_key));
910    }
911
912    // Relations
913    for fk in &table.foreign_keys {
914        output.push_str(&generate_relation(fk, all_tables));
915    }
916
917    // Model attributes
918    let attrs = generate_model_attributes(table);
919    if !attrs.is_empty() {
920        output.push('\n');
921        output.push_str(&attrs);
922    }
923
924    output.push_str("}\n");
925    output
926}
927
928fn generate_field(col: &ColumnInfo, primary_key: &[String]) -> String {
929    let mut attrs = Vec::new();
930
931    // Check if primary key
932    if primary_key.contains(&col.name) {
933        attrs.push("@id".to_string());
934    }
935
936    // Auto increment
937    if col.auto_increment {
938        attrs.push("@auto".to_string());
939    }
940
941    // Unique
942    if col.is_unique && !primary_key.contains(&col.name) {
943        attrs.push("@unique".to_string());
944    }
945
946    // Default
947    if let Some(ref default) = col.default {
948        if !col.auto_increment {
949            let default_val = simplify_default(default);
950            attrs.push(format!("@default({})", default_val));
951        }
952    }
953
954    // Map if name differs
955    let field_name = camel_case(&col.name);
956    if field_name != col.name {
957        attrs.push(format!("@map(\"{}\")", col.name));
958    }
959
960    // Build type string
961    let type_str = col.normalized_type.to_prax_type();
962    let optional = if col.nullable { "?" } else { "" };
963
964    let attrs_str = if attrs.is_empty() {
965        String::new()
966    } else {
967        format!(" {}", attrs.join(" "))
968    };
969
970    format!("    {} {}{}{}\n", field_name, type_str, optional, attrs_str)
971}
972
973fn generate_relation(fk: &ForeignKeyInfo, all_tables: &[TableInfo]) -> String {
974    // Find the referenced table
975    let _ref_table = all_tables.iter().find(|t| t.name == fk.referenced_table);
976    let ref_name = pascal_case(&fk.referenced_table);
977
978    let field_name = if fk.columns.len() == 1 {
979        // Derive relation name from FK column (e.g., user_id -> user)
980        let col = &fk.columns[0];
981        if col.ends_with("_id") {
982            camel_case(&col[..col.len() - 3])
983        } else {
984            camel_case(&fk.referenced_table)
985        }
986    } else {
987        camel_case(&fk.referenced_table)
988    };
989
990    let mut attrs = vec![format!(
991        "@relation(fields: [{}], references: [{}]",
992        fk.columns
993            .iter()
994            .map(|c| camel_case(c))
995            .collect::<Vec<_>>()
996            .join(", "),
997        fk.referenced_columns
998            .iter()
999            .map(|c| camel_case(c))
1000            .collect::<Vec<_>>()
1001            .join(", ")
1002    )];
1003
1004    // Add referential actions if not default
1005    if fk.on_delete != ReferentialAction::NoAction {
1006        attrs[0].push_str(&format!(", onDelete: {}", fk.on_delete.to_prax()));
1007    }
1008    if fk.on_update != ReferentialAction::NoAction {
1009        attrs[0].push_str(&format!(", onUpdate: {}", fk.on_update.to_prax()));
1010    }
1011
1012    attrs[0].push(')');
1013
1014    format!("    {} {} {}\n", field_name, ref_name, attrs.join(" "))
1015}
1016
1017fn generate_model_attributes(table: &TableInfo) -> String {
1018    let mut output = String::new();
1019
1020    // @@map if table name differs from model name
1021    let model_name = pascal_case(&table.name);
1022    if model_name.to_lowercase() != table.name.to_lowercase() {
1023        output.push_str(&format!("    @@map(\"{}\")\n", table.name));
1024    }
1025
1026    // Composite primary key
1027    if table.primary_key.len() > 1 {
1028        let fields: Vec<_> = table.primary_key.iter().map(|c| camel_case(c)).collect();
1029        output.push_str(&format!("    @@id([{}])\n", fields.join(", ")));
1030    }
1031
1032    // Indexes
1033    for idx in &table.indexes {
1034        if !idx.is_primary {
1035            let cols: Vec<_> = idx.columns.iter().map(|c| camel_case(&c.name)).collect();
1036            if idx.is_unique {
1037                output.push_str(&format!("    @@unique([{}])\n", cols.join(", ")));
1038            } else {
1039                output.push_str(&format!("    @@index([{}])\n", cols.join(", ")));
1040            }
1041        }
1042    }
1043
1044    output
1045}
1046
1047fn generate_view(view: &ViewInfo) -> String {
1048    let mut output = String::new();
1049
1050    let keyword = if view.is_materialized {
1051        "materializedView"
1052    } else {
1053        "view"
1054    };
1055    output.push_str(&format!("{} {} {{\n", keyword, pascal_case(&view.name)));
1056
1057    for col in &view.columns {
1058        let type_str = col.normalized_type.to_prax_type();
1059        let optional = if col.nullable { "?" } else { "" };
1060        output.push_str(&format!(
1061            "    {} {}{}\n",
1062            camel_case(&col.name),
1063            type_str,
1064            optional
1065        ));
1066    }
1067
1068    if let Some(ref def) = view.definition {
1069        output.push_str(&format!("\n    @@sql(\"{}\")\n", def.replace('"', "\\\"")));
1070    }
1071
1072    output.push_str("}\n");
1073    output
1074}
1075
1076// ============================================================================
1077// MongoDB Introspection
1078// ============================================================================
1079
1080/// MongoDB collection introspection.
1081pub mod mongodb {
1082    use serde_json::Value as JsonValue;
1083
1084    use super::{ColumnInfo, NormalizedType, TableInfo};
1085
1086    /// Infer schema from MongoDB documents.
1087    #[derive(Debug, Clone, Default)]
1088    pub struct SchemaInferrer {
1089        /// Field types discovered.
1090        pub fields: std::collections::HashMap<String, FieldSchema>,
1091        /// Sample size.
1092        pub samples: usize,
1093    }
1094
1095    /// Inferred field schema.
1096    #[derive(Debug, Clone, Default)]
1097    pub struct FieldSchema {
1098        /// Field name.
1099        pub name: String,
1100        /// Types observed.
1101        pub types: Vec<String>,
1102        /// Whether field is always present.
1103        pub required: bool,
1104        /// Nested fields (for objects).
1105        pub nested: Option<Box<SchemaInferrer>>,
1106        /// Array element type.
1107        pub array_type: Option<String>,
1108    }
1109
1110    impl SchemaInferrer {
1111        /// Create a new inferrer.
1112        pub fn new() -> Self {
1113            Self::default()
1114        }
1115
1116        /// Add a document sample.
1117        pub fn add_document(&mut self, doc: &JsonValue) {
1118            self.samples += 1;
1119
1120            if let Some(obj) = doc.as_object() {
1121                for (key, value) in obj {
1122                    self.infer_field(key, value);
1123                }
1124            }
1125        }
1126
1127        fn infer_field(&mut self, name: &str, value: &JsonValue) {
1128            let field = self
1129                .fields
1130                .entry(name.to_string())
1131                .or_insert_with(|| FieldSchema {
1132                    name: name.to_string(),
1133                    required: true,
1134                    ..Default::default()
1135                });
1136
1137            let type_name = match value {
1138                JsonValue::Null => "null",
1139                JsonValue::Bool(_) => "boolean",
1140                JsonValue::Number(n) if n.is_i64() => "int",
1141                JsonValue::Number(n) if n.is_f64() => "double",
1142                JsonValue::Number(_) => "number",
1143                JsonValue::String(s) => {
1144                    // Try to detect special types
1145                    if s.len() == 24 && s.chars().all(|c| c.is_ascii_hexdigit()) {
1146                        "objectId"
1147                    } else if is_iso_datetime(s) {
1148                        "date"
1149                    } else {
1150                        "string"
1151                    }
1152                }
1153                JsonValue::Array(arr) => {
1154                    if let Some(first) = arr.first() {
1155                        let elem_type = match first {
1156                            JsonValue::Object(_) => "object",
1157                            JsonValue::String(_) => "string",
1158                            JsonValue::Number(_) => "number",
1159                            JsonValue::Bool(_) => "boolean",
1160                            _ => "mixed",
1161                        };
1162                        field.array_type = Some(elem_type.to_string());
1163                    }
1164                    "array"
1165                }
1166                JsonValue::Object(_) => {
1167                    // Recurse for nested objects
1168                    let mut nested = field.nested.take().unwrap_or_default();
1169                    nested.add_document(value);
1170                    field.nested = Some(nested);
1171                    "object"
1172                }
1173            };
1174
1175            if !field.types.contains(&type_name.to_string()) {
1176                field.types.push(type_name.to_string());
1177            }
1178        }
1179
1180        /// Convert to TableInfo.
1181        pub fn to_table_info(&self, collection_name: &str) -> TableInfo {
1182            let mut columns = Vec::new();
1183
1184            for (name, field) in &self.fields {
1185                let normalized = infer_normalized_type(field);
1186                columns.push(ColumnInfo {
1187                    name: name.clone(),
1188                    db_type: field.types.join("|"),
1189                    normalized_type: normalized,
1190                    nullable: !field.required || field.types.contains(&"null".to_string()),
1191                    is_primary_key: name == "_id",
1192                    ..Default::default()
1193                });
1194            }
1195
1196            TableInfo {
1197                name: collection_name.to_string(),
1198                columns,
1199                primary_key: vec!["_id".to_string()],
1200                ..Default::default()
1201            }
1202        }
1203    }
1204
1205    fn infer_normalized_type(field: &FieldSchema) -> NormalizedType {
1206        // Pick most specific type
1207        if field.types.contains(&"objectId".to_string()) {
1208            NormalizedType::String // ObjectId maps to String
1209        } else if field.types.contains(&"date".to_string()) {
1210            NormalizedType::DateTime
1211        } else if field.types.contains(&"boolean".to_string()) {
1212            NormalizedType::Boolean
1213        } else if field.types.contains(&"int".to_string()) {
1214            NormalizedType::Int
1215        } else if field.types.contains(&"double".to_string())
1216            || field.types.contains(&"number".to_string())
1217        {
1218            NormalizedType::Double
1219        } else if field.types.contains(&"array".to_string()) {
1220            let inner = match field.array_type.as_deref() {
1221                Some("string") => NormalizedType::String,
1222                Some("number") => NormalizedType::Double,
1223                Some("boolean") => NormalizedType::Boolean,
1224                _ => NormalizedType::Json,
1225            };
1226            NormalizedType::Array(Box::new(inner))
1227        } else if field.types.contains(&"object".to_string()) {
1228            NormalizedType::Json
1229        } else if field.types.contains(&"string".to_string()) {
1230            NormalizedType::String
1231        } else {
1232            NormalizedType::Unknown(field.types.join("|"))
1233        }
1234    }
1235
1236    /// Generate MongoDB collection indexes command.
1237    pub fn list_indexes_command(collection: &str) -> JsonValue {
1238        serde_json::json!({
1239            "listIndexes": collection
1240        })
1241    }
1242
1243    /// Generate MongoDB list collections command.
1244    pub fn list_collections_command() -> JsonValue {
1245        serde_json::json!({
1246            "listCollections": 1
1247        })
1248    }
1249
1250    /// Simple ISO datetime detection without chrono dependency.
1251    fn is_iso_datetime(s: &str) -> bool {
1252        // Check for ISO 8601 format: YYYY-MM-DDTHH:MM:SS or similar
1253        if s.len() < 10 {
1254            return false;
1255        }
1256
1257        let bytes = s.as_bytes();
1258        // Check YYYY-MM-DD pattern
1259        bytes.get(4) == Some(&b'-')
1260            && bytes.get(7) == Some(&b'-')
1261            && bytes[0..4].iter().all(|b| b.is_ascii_digit())
1262            && bytes[5..7].iter().all(|b| b.is_ascii_digit())
1263            && bytes[8..10].iter().all(|b| b.is_ascii_digit())
1264    }
1265}
1266
1267// ============================================================================
1268// Helpers
1269// ============================================================================
1270
1271fn pascal_case(s: &str) -> String {
1272    s.split('_')
1273        .map(|part| {
1274            let mut chars = part.chars();
1275            match chars.next() {
1276                None => String::new(),
1277                Some(c) => c.to_uppercase().chain(chars).collect(),
1278            }
1279        })
1280        .collect()
1281}
1282
1283fn camel_case(s: &str) -> String {
1284    let pascal = pascal_case(s);
1285    let mut chars = pascal.chars();
1286    match chars.next() {
1287        None => String::new(),
1288        Some(c) => c.to_lowercase().chain(chars).collect(),
1289    }
1290}
1291
1292fn simplify_default(default: &str) -> String {
1293    // Simplify common default expressions
1294    let d = default.trim();
1295
1296    if d.eq_ignore_ascii_case("now()") || d.eq_ignore_ascii_case("current_timestamp") {
1297        return "now()".to_string();
1298    }
1299
1300    if d.starts_with("'") && d.ends_with("'") {
1301        return format!("\"{}\"", &d[1..d.len() - 1]);
1302    }
1303
1304    if d.eq_ignore_ascii_case("true") || d.eq_ignore_ascii_case("false") {
1305        return d.to_lowercase();
1306    }
1307
1308    if d.parse::<i64>().is_ok() || d.parse::<f64>().is_ok() {
1309        return d.to_string();
1310    }
1311
1312    format!("dbgenerated(\"{}\")", d.replace('"', "\\\""))
1313}
1314
1315#[cfg(test)]
1316mod tests {
1317    use super::*;
1318
1319    #[test]
1320    fn test_pascal_case() {
1321        assert_eq!(pascal_case("user_profile"), "UserProfile");
1322        assert_eq!(pascal_case("id"), "Id");
1323        assert_eq!(pascal_case("created_at"), "CreatedAt");
1324    }
1325
1326    #[test]
1327    fn test_camel_case() {
1328        assert_eq!(camel_case("user_profile"), "userProfile");
1329        assert_eq!(camel_case("ID"), "iD");
1330        assert_eq!(camel_case("created_at"), "createdAt");
1331    }
1332
1333    #[test]
1334    fn test_normalize_postgres_type() {
1335        assert_eq!(
1336            normalize_postgres_type("int4", None, None, None),
1337            NormalizedType::Int
1338        );
1339        assert_eq!(
1340            normalize_postgres_type("bigint", None, None, None),
1341            NormalizedType::BigInt
1342        );
1343        assert_eq!(
1344            normalize_postgres_type("text", None, None, None),
1345            NormalizedType::Text
1346        );
1347        assert_eq!(
1348            normalize_postgres_type("timestamptz", None, None, None),
1349            NormalizedType::DateTime
1350        );
1351        assert_eq!(
1352            normalize_postgres_type("jsonb", None, None, None),
1353            NormalizedType::Json
1354        );
1355        assert_eq!(
1356            normalize_postgres_type("uuid", None, None, None),
1357            NormalizedType::Uuid
1358        );
1359    }
1360
1361    #[test]
1362    fn test_normalize_mysql_type() {
1363        assert_eq!(
1364            normalize_mysql_type("int", None, None, None),
1365            NormalizedType::Int
1366        );
1367        assert_eq!(
1368            normalize_mysql_type("varchar", Some(255), None, None),
1369            NormalizedType::VarChar { length: Some(255) }
1370        );
1371        assert_eq!(
1372            normalize_mysql_type("datetime", None, None, None),
1373            NormalizedType::DateTime
1374        );
1375    }
1376
1377    #[test]
1378    fn test_referential_action() {
1379        assert_eq!(
1380            ReferentialAction::from_str("CASCADE"),
1381            ReferentialAction::Cascade
1382        );
1383        assert_eq!(
1384            ReferentialAction::from_str("SET NULL"),
1385            ReferentialAction::SetNull
1386        );
1387        assert_eq!(
1388            ReferentialAction::from_str("NO ACTION"),
1389            ReferentialAction::NoAction
1390        );
1391    }
1392
1393    #[test]
1394    fn test_generate_simple_model() {
1395        let table = TableInfo {
1396            name: "users".to_string(),
1397            columns: vec![
1398                ColumnInfo {
1399                    name: "id".to_string(),
1400                    normalized_type: NormalizedType::Int,
1401                    auto_increment: true,
1402                    ..Default::default()
1403                },
1404                ColumnInfo {
1405                    name: "email".to_string(),
1406                    normalized_type: NormalizedType::String,
1407                    is_unique: true,
1408                    ..Default::default()
1409                },
1410                ColumnInfo {
1411                    name: "created_at".to_string(),
1412                    normalized_type: NormalizedType::DateTime,
1413                    nullable: true,
1414                    default: Some("now()".to_string()),
1415                    ..Default::default()
1416                },
1417            ],
1418            primary_key: vec!["id".to_string()],
1419            ..Default::default()
1420        };
1421
1422        let schema = generate_model(&table, &[]);
1423        assert!(schema.contains("model Users"));
1424        assert!(schema.contains("id Int @id @auto"));
1425        assert!(schema.contains("email String @unique"));
1426        assert!(schema.contains("createdAt DateTime?"));
1427    }
1428
1429    #[test]
1430    fn test_simplify_default() {
1431        assert_eq!(simplify_default("NOW()"), "now()");
1432        assert_eq!(simplify_default("CURRENT_TIMESTAMP"), "now()");
1433        assert_eq!(simplify_default("'hello'"), "\"hello\"");
1434        assert_eq!(simplify_default("42"), "42");
1435        assert_eq!(simplify_default("true"), "true");
1436    }
1437
1438    #[test]
1439    fn test_queries_tables() {
1440        let pg = queries::tables_query(DatabaseType::PostgreSQL, Some("public"));
1441        assert!(pg.contains("information_schema.tables"));
1442        assert!(pg.contains("public"));
1443
1444        let mysql = queries::tables_query(DatabaseType::MySQL, None);
1445        assert!(mysql.contains("information_schema.tables"));
1446
1447        let sqlite = queries::tables_query(DatabaseType::SQLite, None);
1448        assert!(sqlite.contains("sqlite_master"));
1449    }
1450
1451    mod mongodb_tests {
1452        use super::super::mongodb::*;
1453
1454        #[test]
1455        fn test_schema_inferrer() {
1456            let mut inferrer = SchemaInferrer::new();
1457
1458            inferrer.add_document(&serde_json::json!({
1459                "_id": "507f1f77bcf86cd799439011",
1460                "name": "Alice",
1461                "age": 30,
1462                "active": true
1463            }));
1464
1465            inferrer.add_document(&serde_json::json!({
1466                "_id": "507f1f77bcf86cd799439012",
1467                "name": "Bob",
1468                "age": 25,
1469                "active": false,
1470                "email": "bob@example.com"
1471            }));
1472
1473            let table = inferrer.to_table_info("users");
1474            assert_eq!(table.name, "users");
1475            assert!(table.columns.iter().any(|c| c.name == "_id"));
1476            assert!(table.columns.iter().any(|c| c.name == "name"));
1477            assert!(table.columns.iter().any(|c| c.name == "age"));
1478        }
1479    }
1480}