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