Skip to main content

qail_core/migrate/
schema.rs

1//! QAIL Schema Format (Native AST)
2//!
3//! Replaces JSON with a human-readable, intent-aware schema format.
4//!
5//! ```qail
6//! table users {
7//!   id serial primary_key
8//!   name text not_null
9//!   email text nullable unique
10//! }
11//!
12//! unique index idx_users_email on users (email)
13//!
14//! rename users.username -> users.name
15//! ```
16
17use super::policy::{PolicyPermissiveness, PolicyTarget, RlsPolicy};
18use super::types::ColumnType;
19use std::collections::HashMap;
20
21/// A complete database schema.
22#[derive(Debug, Clone, Default)]
23pub struct Schema {
24    /// Declared tables.
25    pub tables: HashMap<String, Table>,
26    /// Declared indexes.
27    pub indexes: Vec<Index>,
28    /// Migration hints (renames, transforms, drops).
29    pub migrations: Vec<MigrationHint>,
30    /// PostgreSQL extensions (e.g. uuid-ossp, pgcrypto, PostGIS)
31    pub extensions: Vec<Extension>,
32    /// Schema-level comments on tables/columns
33    pub comments: Vec<Comment>,
34    /// Standalone sequences
35    pub sequences: Vec<Sequence>,
36    /// Standalone ENUM types
37    pub enums: Vec<EnumType>,
38    /// SQL views (CREATE VIEW / CREATE MATERIALIZED VIEW).
39    pub views: Vec<ViewDef>,
40    /// PL/pgSQL functions
41    pub functions: Vec<SchemaFunctionDef>,
42    /// Database triggers (CREATE TRIGGER).
43    pub triggers: Vec<SchemaTriggerDef>,
44    /// GRANT/REVOKE permissions
45    pub grants: Vec<Grant>,
46    /// RLS policies
47    pub policies: Vec<RlsPolicy>,
48    /// Infrastructure resources (buckets, queues, topics)
49    pub resources: Vec<ResourceDef>,
50}
51
52// ============================================================================
53// Infrastructure Resources
54// ============================================================================
55
56/// Kind of infrastructure resource declared in schema.qail.
57#[derive(Debug, Clone, PartialEq)]
58pub enum ResourceKind {
59    /// Object storage bucket.
60    Bucket,
61    /// Message queue.
62    Queue,
63    /// Pub/sub topic.
64    Topic,
65}
66
67impl std::fmt::Display for ResourceKind {
68    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69        match self {
70            Self::Bucket => write!(f, "bucket"),
71            Self::Queue => write!(f, "queue"),
72            Self::Topic => write!(f, "topic"),
73        }
74    }
75}
76
77/// An infrastructure resource declaration.
78///
79/// ```qail
80/// bucket avatars {
81///     provider s3
82///     region "ap-southeast-1"
83/// }
84/// ```
85#[derive(Debug, Clone)]
86pub struct ResourceDef {
87    /// Resource name (e.g. `"avatars"`).
88    pub name: String,
89    /// Kind of resource.
90    pub kind: ResourceKind,
91    /// Cloud provider (e.g. `"s3"`, `"gcs"`).
92    pub provider: Option<String>,
93    /// Arbitrary key-value properties.
94    pub properties: HashMap<String, String>,
95}
96
97/// A table definition in the schema.
98#[derive(Debug, Clone)]
99pub struct Table {
100    /// Table name.
101    pub name: String,
102    /// Column definitions.
103    pub columns: Vec<Column>,
104    /// Table-level multi-column foreign keys
105    pub multi_column_fks: Vec<MultiColumnForeignKey>,
106    /// ENABLE ROW LEVEL SECURITY
107    pub enable_rls: bool,
108    /// FORCE ROW LEVEL SECURITY
109    pub force_rls: bool,
110}
111
112/// A column definition with compile-time type safety.
113#[derive(Debug, Clone)]
114pub struct Column {
115    /// Column name.
116    pub name: String,
117    /// Compile-time validated data type.
118    pub data_type: ColumnType,
119    /// Whether the column accepts NULL.
120    pub nullable: bool,
121    /// Whether this column is a primary key.
122    pub primary_key: bool,
123    /// Whether this column has a UNIQUE constraint.
124    pub unique: bool,
125    /// Default value expression.
126    pub default: Option<String>,
127    /// Foreign key reference.
128    pub foreign_key: Option<ForeignKey>,
129    /// CHECK constraint (Phase 1)
130    pub check: Option<CheckConstraint>,
131    /// GENERATED column (Phase 3)
132    pub generated: Option<Generated>,
133}
134
135/// Foreign key reference definition.
136#[derive(Debug, Clone)]
137pub struct ForeignKey {
138    /// Referenced table name.
139    pub table: String,
140    /// Referenced column name.
141    pub column: String,
142    /// Action taken when the referenced row is deleted.
143    pub on_delete: FkAction,
144    /// Action taken when the referenced row is updated.
145    pub on_update: FkAction,
146    /// DEFERRABLE clause (Phase 2)
147    pub deferrable: Deferrable,
148}
149
150/// Foreign key action on DELETE/UPDATE.
151#[derive(Debug, Clone, Default, PartialEq)]
152pub enum FkAction {
153    #[default]
154    /// No action on referenced row change.
155    NoAction,
156    /// Cascade the delete/update to referencing rows.
157    Cascade,
158    /// Set referencing column to NULL.
159    SetNull,
160    /// Set referencing column to its DEFAULT.
161    SetDefault,
162    /// Prevent the action (raises error).
163    Restrict,
164}
165
166/// An index definition.
167#[derive(Debug, Clone)]
168pub struct Index {
169    /// Index name.
170    pub name: String,
171    /// Table the index belongs to.
172    pub table: String,
173    /// Columns covered by the index.
174    pub columns: Vec<String>,
175    /// Whether the index enforces uniqueness.
176    pub unique: bool,
177    /// Index method (Phase 4): btree, hash, gin, gist, brin
178    pub method: IndexMethod,
179    /// Partial index WHERE clause
180    pub where_clause: Option<CheckExpr>,
181    /// INCLUDE columns (covering index)
182    pub include: Vec<String>,
183    /// CREATE CONCURRENTLY
184    pub concurrently: bool,
185    /// Expression columns (e.g. `(lower(email))`) — if set, these replace `columns`
186    pub expressions: Vec<String>,
187}
188
189/// Hints for the migration diff engine to improve migration quality.
190#[derive(Debug, Clone)]
191pub enum MigrationHint {
192    /// Rename a column (not delete + add)
193    Rename {
194        /// Original column name.
195        from: String,
196        /// New column name.
197        to: String,
198    },
199    /// Transform data with expression
200    Transform {
201        /// SQL expression for data transformation.
202        expression: String,
203        /// Target column name.
204        target: String,
205    },
206    /// Drop with confirmation
207    Drop {
208        /// Target name to drop.
209        target: String,
210        /// Whether the drop has been confirmed.
211        confirmed: bool,
212    },
213}
214
215// ============================================================================
216// Phase 1: CHECK Constraints (AST-native)
217// ============================================================================
218
219/// CHECK constraint expression (AST-native, no raw SQL)
220#[derive(Debug, Clone)]
221pub enum CheckExpr {
222    /// column > value
223    GreaterThan {
224        /// Column name.
225        column: String,
226        /// Comparison value.
227        value: i64,
228    },
229    /// column >= value
230    GreaterOrEqual {
231        /// Column name.
232        column: String,
233        /// Comparison value.
234        value: i64,
235    },
236    /// column < value
237    LessThan {
238        /// Column name.
239        column: String,
240        /// Comparison value.
241        value: i64,
242    },
243    /// column <= value
244    LessOrEqual {
245        /// Column name.
246        column: String,
247        /// Comparison value.
248        value: i64,
249    },
250    /// value BETWEEN low AND high
251    Between {
252        /// Column name.
253        column: String,
254        /// Lower bound.
255        low: i64,
256        /// Upper bound.
257        high: i64,
258    },
259    /// column IN (values)
260    In {
261        /// Column name.
262        column: String,
263        /// Allowed values.
264        values: Vec<String>,
265    },
266    /// column ~ pattern (regex)
267    Regex {
268        /// Column name.
269        column: String,
270        /// Regex pattern.
271        pattern: String,
272    },
273    /// LENGTH(column) <= max
274    MaxLength {
275        /// Column name.
276        column: String,
277        /// Maximum allowed length.
278        max: usize,
279    },
280    /// LENGTH(column) >= min
281    MinLength {
282        /// Column name.
283        column: String,
284        /// Minimum required length.
285        min: usize,
286    },
287    /// column IS NOT NULL
288    NotNull {
289        /// Column name.
290        column: String,
291    },
292    /// Logical AND of two expressions.
293    And(Box<CheckExpr>, Box<CheckExpr>),
294    /// Logical OR of two expressions.
295    Or(Box<CheckExpr>, Box<CheckExpr>),
296    /// Logical NOT of an expression.
297    Not(Box<CheckExpr>),
298    /// SQL boolean expression (preserved as-is).
299    Sql(String),
300}
301
302/// CHECK constraint with optional name
303#[derive(Debug, Clone)]
304pub struct CheckConstraint {
305    /// The constraint expression.
306    pub expr: CheckExpr,
307    /// Optional constraint name.
308    pub name: Option<String>,
309}
310
311// ============================================================================
312// Phase 2: DEFERRABLE Constraints
313// ============================================================================
314
315/// Constraint deferral mode
316#[derive(Debug, Clone, Default, PartialEq)]
317pub enum Deferrable {
318    #[default]
319    /// Not deferrable (default).
320    NotDeferrable,
321    /// DEFERRABLE (initially immediate).
322    Deferrable,
323    /// DEFERRABLE INITIALLY DEFERRED.
324    InitiallyDeferred,
325    /// DEFERRABLE INITIALLY IMMEDIATE.
326    InitiallyImmediate,
327}
328
329// ============================================================================
330// Phase 3: GENERATED Columns
331// ============================================================================
332
333/// GENERATED column type
334#[derive(Debug, Clone)]
335pub enum Generated {
336    /// GENERATED ALWAYS AS (expr) STORED
337    AlwaysStored(String),
338    /// GENERATED ALWAYS AS IDENTITY
339    AlwaysIdentity,
340    /// GENERATED BY DEFAULT AS IDENTITY
341    ByDefaultIdentity,
342}
343
344// ============================================================================
345// Phase 4: Advanced Index Types
346// ============================================================================
347
348/// Index method (USING clause)
349#[derive(Debug, Clone, Default, PartialEq)]
350pub enum IndexMethod {
351    #[default]
352    /// B-tree (default for most columns).
353    BTree,
354    /// Hash (equality-only lookups).
355    Hash,
356    /// GIN (full-text search, JSONB).
357    Gin,
358    /// GiST (geometric, range types).
359    Gist,
360    /// BRIN (large, naturally-ordered tables).
361    Brin,
362    /// SP-GiST (space-partitioned).
363    SpGist,
364    /// HNSW vector index (pgvector).
365    Hnsw,
366    /// IVFFlat vector index (pgvector).
367    IvfFlat,
368}
369
370pub(crate) fn index_method_str(method: &IndexMethod) -> &'static str {
371    match method {
372        IndexMethod::BTree => "btree",
373        IndexMethod::Hash => "hash",
374        IndexMethod::Gin => "gin",
375        IndexMethod::Gist => "gist",
376        IndexMethod::Brin => "brin",
377        IndexMethod::SpGist => "spgist",
378        IndexMethod::Hnsw => "hnsw",
379        IndexMethod::IvfFlat => "ivfflat",
380    }
381}
382
383// ============================================================================
384// Phase 7: Extensions, Comments, Sequences
385// ============================================================================
386
387/// PostgreSQL extension (e.g. `CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`)
388#[derive(Debug, Clone, PartialEq)]
389pub struct Extension {
390    /// Extension name (e.g. `"uuid-ossp"`).
391    pub name: String,
392    /// Target schema.
393    pub schema: Option<String>,
394    /// Pinned version.
395    pub version: Option<String>,
396}
397
398impl Extension {
399    /// Create a new extension declaration.
400    pub fn new(name: impl Into<String>) -> Self {
401        Self {
402            name: name.into(),
403            schema: None,
404            version: None,
405        }
406    }
407
408    /// Set the target schema.
409    pub fn schema(mut self, schema: impl Into<String>) -> Self {
410        self.schema = Some(schema.into());
411        self
412    }
413
414    /// Pin to a specific version.
415    pub fn version(mut self, version: impl Into<String>) -> Self {
416        self.version = Some(version.into());
417        self
418    }
419}
420
421/// COMMENT ON TABLE/COLUMN
422#[derive(Debug, Clone, PartialEq)]
423pub struct Comment {
424    /// What the comment is attached to.
425    pub target: CommentTarget,
426    /// Comment text.
427    pub text: String,
428}
429
430/// Target of a COMMENT ON statement.
431#[derive(Debug, Clone, PartialEq)]
432pub enum CommentTarget {
433    /// COMMENT ON TABLE.
434    Table(String),
435    /// COMMENT ON COLUMN.
436    Column {
437        /// Table name.
438        table: String,
439        /// Column name.
440        column: String,
441    },
442    /// COMMENT ON arbitrary object target (e.g. FUNCTION/POLICY/TYPE/CONSTRAINT).
443    Raw(String),
444}
445
446impl Comment {
447    /// Create a comment on a table.
448    pub fn on_table(table: impl Into<String>, text: impl Into<String>) -> Self {
449        Self {
450            target: CommentTarget::Table(table.into()),
451            text: text.into(),
452        }
453    }
454
455    /// Create a comment on a column.
456    pub fn on_column(
457        table: impl Into<String>,
458        column: impl Into<String>,
459        text: impl Into<String>,
460    ) -> Self {
461        Self {
462            target: CommentTarget::Column {
463                table: table.into(),
464                column: column.into(),
465            },
466            text: text.into(),
467        }
468    }
469
470    /// Create a comment on an arbitrary object target.
471    pub fn on_raw(target: impl Into<String>, text: impl Into<String>) -> Self {
472        Self {
473            target: CommentTarget::Raw(target.into()),
474            text: text.into(),
475        }
476    }
477}
478
479/// Standalone sequence (CREATE SEQUENCE)
480#[derive(Debug, Clone, PartialEq)]
481pub struct Sequence {
482    /// Sequence name.
483    pub name: String,
484    /// Data type (e.g. `"bigint"`).
485    pub data_type: Option<String>,
486    /// START WITH value.
487    pub start: Option<i64>,
488    /// INCREMENT BY value.
489    pub increment: Option<i64>,
490    /// Minimum value for the sequence (MINVALUE clause).
491    pub min_value: Option<i64>,
492    /// Maximum value for the sequence (MAXVALUE clause).
493    pub max_value: Option<i64>,
494    /// CACHE size.
495    pub cache: Option<i64>,
496    /// Whether the sequence wraps around.
497    pub cycle: bool,
498    /// OWNED BY column reference.
499    pub owned_by: Option<String>,
500}
501
502impl Sequence {
503    /// Create a new sequence.
504    pub fn new(name: impl Into<String>) -> Self {
505        Self {
506            name: name.into(),
507            data_type: None,
508            start: None,
509            increment: None,
510            min_value: None,
511            max_value: None,
512            cache: None,
513            cycle: false,
514            owned_by: None,
515        }
516    }
517
518    /// Set the START WITH value.
519    pub fn start(mut self, v: i64) -> Self {
520        self.start = Some(v);
521        self
522    }
523
524    /// Set the INCREMENT BY value.
525    pub fn increment(mut self, v: i64) -> Self {
526        self.increment = Some(v);
527        self
528    }
529
530    /// Set the MINVALUE.
531    pub fn min_value(mut self, v: i64) -> Self {
532        self.min_value = Some(v);
533        self
534    }
535
536    /// Set the MAXVALUE.
537    pub fn max_value(mut self, v: i64) -> Self {
538        self.max_value = Some(v);
539        self
540    }
541
542    /// Set the CACHE size.
543    pub fn cache(mut self, v: i64) -> Self {
544        self.cache = Some(v);
545        self
546    }
547
548    /// Enable CYCLE (wrap around at limit).
549    pub fn cycle(mut self) -> Self {
550        self.cycle = true;
551        self
552    }
553
554    /// Set the OWNED BY column reference.
555    pub fn owned_by(mut self, col: impl Into<String>) -> Self {
556        self.owned_by = Some(col.into());
557        self
558    }
559}
560
561// ============================================================================
562// Phase 8: Standalone Enums, Multi-Column FK
563// ============================================================================
564
565/// Standalone ENUM type (CREATE TYPE ... AS ENUM)
566#[derive(Debug, Clone, PartialEq)]
567pub struct EnumType {
568    /// Type name.
569    pub name: String,
570    /// Allowed values.
571    pub values: Vec<String>,
572}
573
574impl EnumType {
575    /// Create a new enum type.
576    pub fn new(name: impl Into<String>, values: Vec<String>) -> Self {
577        Self {
578            name: name.into(),
579            values,
580        }
581    }
582
583    /// Add a new value (for ALTER TYPE ADD VALUE)
584    pub fn add_value(mut self, value: impl Into<String>) -> Self {
585        self.values.push(value.into());
586        self
587    }
588}
589
590/// Table-level multi-column foreign key
591#[derive(Debug, Clone, PartialEq)]
592pub struct MultiColumnForeignKey {
593    /// Source columns.
594    pub columns: Vec<String>,
595    /// Referenced table.
596    pub ref_table: String,
597    /// Referenced columns.
598    pub ref_columns: Vec<String>,
599    /// ON DELETE action.
600    pub on_delete: FkAction,
601    /// ON UPDATE action.
602    pub on_update: FkAction,
603    /// Deferral mode.
604    pub deferrable: Deferrable,
605    /// Optional constraint name.
606    pub name: Option<String>,
607}
608
609impl MultiColumnForeignKey {
610    /// Create a new multi-column foreign key.
611    pub fn new(
612        columns: Vec<String>,
613        ref_table: impl Into<String>,
614        ref_columns: Vec<String>,
615    ) -> Self {
616        Self {
617            columns,
618            ref_table: ref_table.into(),
619            ref_columns,
620            on_delete: FkAction::default(),
621            on_update: FkAction::default(),
622            deferrable: Deferrable::default(),
623            name: None,
624        }
625    }
626
627    /// Set the ON DELETE action.
628    pub fn on_delete(mut self, action: FkAction) -> Self {
629        self.on_delete = action;
630        self
631    }
632
633    /// Set the ON UPDATE action.
634    pub fn on_update(mut self, action: FkAction) -> Self {
635        self.on_update = action;
636        self
637    }
638
639    /// Set an explicit constraint name.
640    pub fn named(mut self, name: impl Into<String>) -> Self {
641        self.name = Some(name.into());
642        self
643    }
644
645    /// Make the foreign key DEFERRABLE.
646    pub fn deferrable(mut self) -> Self {
647        self.deferrable = Deferrable::Deferrable;
648        self
649    }
650
651    /// Make the foreign key DEFERRABLE INITIALLY DEFERRED.
652    pub fn initially_deferred(mut self) -> Self {
653        self.deferrable = Deferrable::InitiallyDeferred;
654        self
655    }
656
657    /// Make the foreign key DEFERRABLE INITIALLY IMMEDIATE.
658    pub fn initially_immediate(mut self) -> Self {
659        self.deferrable = Deferrable::InitiallyImmediate;
660        self
661    }
662}
663
664// ============================================================================
665// Phase 9: Views, Functions, Triggers, Grants
666// ============================================================================
667
668/// A SQL view definition.
669#[derive(Debug, Clone, PartialEq)]
670pub struct ViewDef {
671    /// View name.
672    pub name: String,
673    /// Underlying SQL query.
674    pub query: String,
675    /// Whether this is a MATERIALIZED VIEW.
676    pub materialized: bool,
677}
678
679impl ViewDef {
680    /// Create a standard (non-materialized) view.
681    pub fn new(name: impl Into<String>, query: impl Into<String>) -> Self {
682        Self {
683            name: name.into(),
684            query: query.into(),
685            materialized: false,
686        }
687    }
688
689    /// Mark as MATERIALIZED VIEW.
690    pub fn materialized(mut self) -> Self {
691        self.materialized = true;
692        self
693    }
694}
695
696/// A PL/pgSQL function definition for the schema model.
697#[derive(Debug, Clone, PartialEq)]
698pub struct SchemaFunctionDef {
699    /// Function name.
700    pub name: String,
701    /// Function arguments (e.g. `"p_id uuid"`).
702    pub args: Vec<String>,
703    /// Return type.
704    pub returns: String,
705    /// Function body.
706    pub body: String,
707    /// Language (default `"plpgsql"`).
708    pub language: String,
709    /// Volatility category (VOLATILE, STABLE, IMMUTABLE).
710    pub volatility: Option<String>,
711}
712
713impl SchemaFunctionDef {
714    /// Create a new function definition.
715    pub fn new(
716        name: impl Into<String>,
717        returns: impl Into<String>,
718        body: impl Into<String>,
719    ) -> Self {
720        Self {
721            name: name.into(),
722            args: Vec::new(),
723            returns: returns.into(),
724            body: body.into(),
725            language: "plpgsql".to_string(),
726            volatility: None,
727        }
728    }
729
730    /// Set the function language.
731    pub fn language(mut self, lang: impl Into<String>) -> Self {
732        self.language = lang.into();
733        self
734    }
735
736    /// Add a function argument.
737    pub fn arg(mut self, arg: impl Into<String>) -> Self {
738        self.args.push(arg.into());
739        self
740    }
741
742    /// Set the volatility category.
743    pub fn volatility(mut self, v: impl Into<String>) -> Self {
744        self.volatility = Some(v.into());
745        self
746    }
747}
748
749/// A trigger definition for the schema model.
750#[derive(Debug, Clone, PartialEq)]
751pub struct SchemaTriggerDef {
752    /// Trigger name.
753    pub name: String,
754    /// Target table.
755    pub table: String,
756    /// Timing (BEFORE, AFTER, INSTEAD OF).
757    pub timing: String,
758    /// Events that fire the trigger (INSERT, UPDATE, DELETE).
759    pub events: Vec<String>,
760    /// Optional column list for `UPDATE OF` triggers.
761    pub update_columns: Vec<String>,
762    /// Whether the trigger fires FOR EACH ROW (vs. FOR EACH STATEMENT).
763    pub for_each_row: bool,
764    /// Function to execute.
765    pub execute_function: String,
766    /// Optional WHEN condition.
767    pub condition: Option<String>,
768}
769
770impl SchemaTriggerDef {
771    /// Create a new trigger definition.
772    pub fn new(
773        name: impl Into<String>,
774        table: impl Into<String>,
775        execute_function: impl Into<String>,
776    ) -> Self {
777        Self {
778            name: name.into(),
779            table: table.into(),
780            timing: "BEFORE".to_string(),
781            events: vec!["INSERT".to_string()],
782            update_columns: Vec::new(),
783            for_each_row: true,
784            execute_function: execute_function.into(),
785            condition: None,
786        }
787    }
788
789    /// Set the trigger timing.
790    pub fn timing(mut self, t: impl Into<String>) -> Self {
791        self.timing = t.into();
792        self
793    }
794
795    /// Set the trigger events.
796    pub fn events(mut self, evts: Vec<String>) -> Self {
797        self.events = evts;
798        self
799    }
800
801    /// Fire FOR EACH STATEMENT instead of FOR EACH ROW.
802    pub fn for_each_statement(mut self) -> Self {
803        self.for_each_row = false;
804        self
805    }
806
807    /// Set an optional WHEN condition.
808    pub fn condition(mut self, cond: impl Into<String>) -> Self {
809        self.condition = Some(cond.into());
810        self
811    }
812}
813
814/// GRANT or REVOKE permission.
815#[derive(Debug, Clone, PartialEq)]
816pub struct Grant {
817    /// GRANT or REVOKE.
818    pub action: GrantAction,
819    /// Privileges being granted/revoked.
820    pub privileges: Vec<Privilege>,
821    /// Target object (table, schema, sequence).
822    pub on_object: String,
823    /// Role receiving (or losing) the privileges.
824    pub to_role: String,
825}
826
827/// Whether a permission statement is a GRANT or REVOKE.
828#[derive(Debug, Clone, PartialEq, Default)]
829pub enum GrantAction {
830    #[default]
831    /// Grant privileges.
832    Grant,
833    /// Revoke privileges.
834    Revoke,
835}
836
837/// SQL privilege type.
838#[derive(Debug, Clone, PartialEq)]
839pub enum Privilege {
840    /// ALL PRIVILEGES.
841    All,
842    /// SELECT.
843    Select,
844    /// INSERT.
845    Insert,
846    /// UPDATE.
847    Update,
848    /// DELETE.
849    Delete,
850    /// USAGE (on schemas, sequences).
851    Usage,
852    /// EXECUTE (on functions).
853    Execute,
854}
855
856impl std::fmt::Display for Privilege {
857    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
858        match self {
859            Privilege::All => write!(f, "ALL"),
860            Privilege::Select => write!(f, "SELECT"),
861            Privilege::Insert => write!(f, "INSERT"),
862            Privilege::Update => write!(f, "UPDATE"),
863            Privilege::Delete => write!(f, "DELETE"),
864            Privilege::Usage => write!(f, "USAGE"),
865            Privilege::Execute => write!(f, "EXECUTE"),
866        }
867    }
868}
869
870impl Grant {
871    /// Create a GRANT statement.
872    pub fn new(
873        privileges: Vec<Privilege>,
874        on_object: impl Into<String>,
875        to_role: impl Into<String>,
876    ) -> Self {
877        Self {
878            action: GrantAction::Grant,
879            privileges,
880            on_object: on_object.into(),
881            to_role: to_role.into(),
882        }
883    }
884
885    /// Create a REVOKE statement.
886    pub fn revoke(
887        privileges: Vec<Privilege>,
888        on_object: impl Into<String>,
889        from_role: impl Into<String>,
890    ) -> Self {
891        Self {
892            action: GrantAction::Revoke,
893            privileges,
894            on_object: on_object.into(),
895            to_role: from_role.into(),
896        }
897    }
898}
899
900impl Schema {
901    /// Create an empty schema.
902    pub fn new() -> Self {
903        Self::default()
904    }
905
906    /// Add a table definition.
907    pub fn add_table(&mut self, table: Table) {
908        self.tables.insert(table.name.clone(), table);
909    }
910
911    /// Add an index definition.
912    pub fn add_index(&mut self, index: Index) {
913        self.indexes.push(index);
914    }
915
916    /// Add a migration hint.
917    pub fn add_hint(&mut self, hint: MigrationHint) {
918        self.migrations.push(hint);
919    }
920
921    /// Add a PostgreSQL extension.
922    pub fn add_extension(&mut self, ext: Extension) {
923        self.extensions.push(ext);
924    }
925
926    /// Add a schema comment.
927    pub fn add_comment(&mut self, comment: Comment) {
928        self.comments.push(comment);
929    }
930
931    /// Add a standalone sequence.
932    pub fn add_sequence(&mut self, seq: Sequence) {
933        self.sequences.push(seq);
934    }
935
936    /// Add a standalone ENUM type.
937    pub fn add_enum(&mut self, enum_type: EnumType) {
938        self.enums.push(enum_type);
939    }
940
941    /// Add a view definition.
942    pub fn add_view(&mut self, view: ViewDef) {
943        self.views.push(view);
944    }
945
946    /// Add a function definition.
947    pub fn add_function(&mut self, func: SchemaFunctionDef) {
948        self.functions.push(func);
949    }
950
951    /// Add a trigger definition.
952    pub fn add_trigger(&mut self, trigger: SchemaTriggerDef) {
953        self.triggers.push(trigger);
954    }
955
956    /// Add a GRANT or REVOKE.
957    pub fn add_grant(&mut self, grant: Grant) {
958        self.grants.push(grant);
959    }
960
961    /// Add an infrastructure resource declaration.
962    pub fn add_resource(&mut self, resource: ResourceDef) {
963        self.resources.push(resource);
964    }
965
966    /// Add an RLS policy definition.
967    pub fn add_policy(&mut self, policy: RlsPolicy) {
968        self.policies.push(policy);
969    }
970
971    /// Validate all foreign key references in the schema.
972    pub fn validate(&self) -> Result<(), Vec<String>> {
973        let mut errors = Vec::new();
974
975        for table in self.tables.values() {
976            for col in &table.columns {
977                if let Some(ref fk) = col.foreign_key {
978                    if !self.tables.contains_key(&fk.table) {
979                        errors.push(format!(
980                            "FK error: {}.{} references non-existent table '{}'",
981                            table.name, col.name, fk.table
982                        ));
983                    } else {
984                        let ref_table = &self.tables[&fk.table];
985                        if !ref_table.columns.iter().any(|c| c.name == fk.column) {
986                            errors.push(format!(
987                                "FK error: {}.{} references non-existent column '{}.{}'",
988                                table.name, col.name, fk.table, fk.column
989                            ));
990                        }
991                    }
992                }
993            }
994        }
995
996        if errors.is_empty() {
997            Ok(())
998        } else {
999            Err(errors)
1000        }
1001    }
1002}
1003
1004impl Table {
1005    /// Create a new empty table.
1006    pub fn new(name: impl Into<String>) -> Self {
1007        Self {
1008            name: name.into(),
1009            columns: Vec::new(),
1010            multi_column_fks: Vec::new(),
1011            enable_rls: false,
1012            force_rls: false,
1013        }
1014    }
1015
1016    /// Add a column (builder pattern).
1017    pub fn column(mut self, col: Column) -> Self {
1018        self.columns.push(col);
1019        self
1020    }
1021
1022    /// Add a table-level multi-column foreign key
1023    pub fn foreign_key(mut self, fk: MultiColumnForeignKey) -> Self {
1024        self.multi_column_fks.push(fk);
1025        self
1026    }
1027}
1028
1029impl Column {
1030    fn primary_key_type_error(&self) -> String {
1031        format!(
1032            "Column '{}' of type {} cannot be a primary key. \
1033             Valid PK types: scalar/indexable types \
1034             (UUID, TEXT, VARCHAR, INT, BIGINT, SERIAL, BIGSERIAL, BOOLEAN, FLOAT, DECIMAL, \
1035             TIMESTAMP, TIMESTAMPTZ, DATE, TIME, ENUM, INET, CIDR, MACADDR)",
1036            self.name,
1037            self.data_type.name()
1038        )
1039    }
1040
1041    fn unique_type_error(&self) -> String {
1042        format!(
1043            "Column '{}' of type {} cannot have UNIQUE constraint. \
1044             JSONB and BYTEA types do not support standard indexing.",
1045            self.name,
1046            self.data_type.name()
1047        )
1048    }
1049
1050    /// Create a new column with compile-time type validation.
1051    pub fn new(name: impl Into<String>, data_type: ColumnType) -> Self {
1052        Self {
1053            name: name.into(),
1054            data_type,
1055            nullable: true,
1056            primary_key: false,
1057            unique: false,
1058            default: None,
1059            foreign_key: None,
1060            check: None,
1061            generated: None,
1062        }
1063    }
1064
1065    /// Mark as NOT NULL.
1066    pub fn not_null(mut self) -> Self {
1067        self.nullable = false;
1068        self
1069    }
1070
1071    /// Set as primary key with compile-time validation.
1072    /// Validates that the column type can be a primary key.
1073    ///
1074    /// This method is fail-soft: invalid type combinations are allowed to
1075    /// continue without panicking so production callers cannot crash on
1076    /// dynamic schema input. Use [`Column::try_primary_key`] for strict mode.
1077    pub fn primary_key(mut self) -> Self {
1078        if !self.data_type.can_be_primary_key() {
1079            #[cfg(debug_assertions)]
1080            eprintln!("QAIL: {}", self.primary_key_type_error());
1081        }
1082        self.primary_key = true;
1083        self.nullable = false;
1084        self
1085    }
1086
1087    /// Strict variant of [`Column::primary_key`].
1088    ///
1089    /// Returns an error instead of panicking when type policy disallows PK.
1090    pub fn try_primary_key(mut self) -> Result<Self, String> {
1091        if !self.data_type.can_be_primary_key() {
1092            return Err(self.primary_key_type_error());
1093        }
1094        self.primary_key = true;
1095        self.nullable = false;
1096        Ok(self)
1097    }
1098
1099    /// Set as unique with compile-time validation.
1100    /// Validates that the column type supports indexing.
1101    ///
1102    /// This method is fail-soft: invalid type combinations are allowed to
1103    /// continue without panicking so production callers cannot crash on
1104    /// dynamic schema input. Use [`Column::try_unique`] for strict mode.
1105    pub fn unique(mut self) -> Self {
1106        if !self.data_type.supports_indexing() {
1107            #[cfg(debug_assertions)]
1108            eprintln!("QAIL: {}", self.unique_type_error());
1109        }
1110        self.unique = true;
1111        self
1112    }
1113
1114    /// Strict variant of [`Column::unique`].
1115    ///
1116    /// Returns an error instead of panicking when type policy disallows UNIQUE.
1117    pub fn try_unique(mut self) -> Result<Self, String> {
1118        if !self.data_type.supports_indexing() {
1119            return Err(self.unique_type_error());
1120        }
1121        self.unique = true;
1122        Ok(self)
1123    }
1124
1125    /// Set a DEFAULT value expression.
1126    pub fn default(mut self, val: impl Into<String>) -> Self {
1127        self.default = Some(val.into());
1128        self
1129    }
1130
1131    /// Add a foreign key reference to another table.
1132    /// # Example
1133    /// ```ignore
1134    /// Column::new("user_id", ColumnType::Uuid)
1135    ///     .references("users", "id")
1136    ///     .on_delete(FkAction::Cascade)
1137    /// ```
1138    pub fn references(mut self, table: &str, column: &str) -> Self {
1139        self.foreign_key = Some(ForeignKey {
1140            table: table.to_string(),
1141            column: column.to_string(),
1142            on_delete: FkAction::default(),
1143            on_update: FkAction::default(),
1144            deferrable: Deferrable::default(),
1145        });
1146        self
1147    }
1148
1149    /// Set the ON DELETE action for the foreign key.
1150    pub fn on_delete(mut self, action: FkAction) -> Self {
1151        if let Some(ref mut fk) = self.foreign_key {
1152            fk.on_delete = action;
1153        }
1154        self
1155    }
1156
1157    /// Set the ON UPDATE action for the foreign key.
1158    pub fn on_update(mut self, action: FkAction) -> Self {
1159        if let Some(ref mut fk) = self.foreign_key {
1160            fk.on_update = action;
1161        }
1162        self
1163    }
1164
1165    // ==================== Phase 1: CHECK ====================
1166
1167    /// Add a CHECK constraint (AST-native)
1168    pub fn check(mut self, expr: CheckExpr) -> Self {
1169        self.check = Some(CheckConstraint { expr, name: None });
1170        self
1171    }
1172
1173    /// Add a named CHECK constraint
1174    pub fn check_named(mut self, name: impl Into<String>, expr: CheckExpr) -> Self {
1175        self.check = Some(CheckConstraint {
1176            expr,
1177            name: Some(name.into()),
1178        });
1179        self
1180    }
1181
1182    // ==================== Phase 2: DEFERRABLE ====================
1183
1184    /// Make foreign key DEFERRABLE
1185    pub fn deferrable(mut self) -> Self {
1186        if let Some(ref mut fk) = self.foreign_key {
1187            fk.deferrable = Deferrable::Deferrable;
1188        }
1189        self
1190    }
1191
1192    /// Make foreign key DEFERRABLE INITIALLY DEFERRED
1193    pub fn initially_deferred(mut self) -> Self {
1194        if let Some(ref mut fk) = self.foreign_key {
1195            fk.deferrable = Deferrable::InitiallyDeferred;
1196        }
1197        self
1198    }
1199
1200    /// Make foreign key DEFERRABLE INITIALLY IMMEDIATE
1201    pub fn initially_immediate(mut self) -> Self {
1202        if let Some(ref mut fk) = self.foreign_key {
1203            fk.deferrable = Deferrable::InitiallyImmediate;
1204        }
1205        self
1206    }
1207
1208    // ==================== Phase 3: GENERATED ====================
1209
1210    /// GENERATED ALWAYS AS (expr) STORED
1211    pub fn generated_stored(mut self, expr: impl Into<String>) -> Self {
1212        self.generated = Some(Generated::AlwaysStored(expr.into()));
1213        self
1214    }
1215
1216    /// GENERATED ALWAYS AS IDENTITY
1217    pub fn generated_identity(mut self) -> Self {
1218        self.generated = Some(Generated::AlwaysIdentity);
1219        self
1220    }
1221
1222    /// GENERATED BY DEFAULT AS IDENTITY
1223    pub fn generated_by_default(mut self) -> Self {
1224        self.generated = Some(Generated::ByDefaultIdentity);
1225        self
1226    }
1227}
1228
1229impl Index {
1230    /// Create a new index on the given columns.
1231    pub fn new(name: impl Into<String>, table: impl Into<String>, columns: Vec<String>) -> Self {
1232        Self {
1233            name: name.into(),
1234            table: table.into(),
1235            columns,
1236            unique: false,
1237            method: IndexMethod::default(),
1238            where_clause: None,
1239            include: Vec::new(),
1240            concurrently: false,
1241            expressions: Vec::new(),
1242        }
1243    }
1244
1245    /// Create an expression index (e.g. `CREATE INDEX ON t ((lower(email)))`)
1246    pub fn expression(
1247        name: impl Into<String>,
1248        table: impl Into<String>,
1249        expressions: Vec<String>,
1250    ) -> Self {
1251        Self {
1252            name: name.into(),
1253            table: table.into(),
1254            columns: Vec::new(),
1255            unique: false,
1256            method: IndexMethod::default(),
1257            where_clause: None,
1258            include: Vec::new(),
1259            concurrently: false,
1260            expressions,
1261        }
1262    }
1263
1264    /// Mark this index as UNIQUE.
1265    pub fn unique(mut self) -> Self {
1266        self.unique = true;
1267        self
1268    }
1269
1270    // ==================== Phase 4: Advanced Index Options ====================
1271
1272    /// Set index method (USING clause)
1273    pub fn using(mut self, method: IndexMethod) -> Self {
1274        self.method = method;
1275        self
1276    }
1277
1278    /// Create a partial index with WHERE clause
1279    pub fn partial(mut self, expr: CheckExpr) -> Self {
1280        self.where_clause = Some(expr);
1281        self
1282    }
1283
1284    /// Add INCLUDE columns (covering index)
1285    pub fn include(mut self, cols: Vec<String>) -> Self {
1286        self.include = cols;
1287        self
1288    }
1289
1290    /// Create index CONCURRENTLY
1291    pub fn concurrently(mut self) -> Self {
1292        self.concurrently = true;
1293        self
1294    }
1295}
1296
1297/// Format a Schema to .qail format string.
1298/// Convert FkAction to its QAIL string representation
1299fn fk_action_str(action: &FkAction) -> &'static str {
1300    match action {
1301        FkAction::NoAction => "no_action",
1302        FkAction::Cascade => "cascade",
1303        FkAction::SetNull => "set_null",
1304        FkAction::SetDefault => "set_default",
1305        FkAction::Restrict => "restrict",
1306    }
1307}
1308
1309fn format_qail_value_token(value: &str, extra_special: &[char]) -> String {
1310    let needs_quotes = value.is_empty()
1311        || value.chars().any(|ch| {
1312            ch.is_whitespace() || matches!(ch, ',' | '\'' | '"') || extra_special.contains(&ch)
1313        });
1314
1315    if needs_quotes {
1316        format!("\"{}\"", value.replace('"', "\"\""))
1317    } else {
1318        value.to_string()
1319    }
1320}
1321
1322fn format_check_in_value(value: &str) -> String {
1323    format_qail_value_token(value, &['[', ']'])
1324}
1325
1326/// Serialize CheckExpr to QAIL check syntax
1327fn check_expr_str(expr: &CheckExpr) -> String {
1328    match expr {
1329        CheckExpr::GreaterThan { column, value } => format!("{} > {}", column, value),
1330        CheckExpr::GreaterOrEqual { column, value } => format!("{} >= {}", column, value),
1331        CheckExpr::LessThan { column, value } => format!("{} < {}", column, value),
1332        CheckExpr::LessOrEqual { column, value } => format!("{} <= {}", column, value),
1333        CheckExpr::Between { column, low, high } => format!("{} between {} {}", column, low, high),
1334        CheckExpr::In { column, values } => format!(
1335            "{} in [{}]",
1336            column,
1337            values
1338                .iter()
1339                .map(|value| format_check_in_value(value))
1340                .collect::<Vec<_>>()
1341                .join(", ")
1342        ),
1343        CheckExpr::Regex { column, pattern } => {
1344            format!("{} ~ '{}'", column, pattern.replace('\'', "''"))
1345        }
1346        CheckExpr::MaxLength { column, max } => format!("length({}) <= {}", column, max),
1347        CheckExpr::MinLength { column, min } => format!("length({}) >= {}", column, min),
1348        CheckExpr::NotNull { column } => format!("{} not_null", column),
1349        CheckExpr::And(l, r) => format!("{} and {}", check_expr_str(l), check_expr_str(r)),
1350        CheckExpr::Or(l, r) => format!("{} or {}", check_expr_str(l), check_expr_str(r)),
1351        CheckExpr::Not(e) => format!("not {}", check_expr_str(e)),
1352        CheckExpr::Sql(sql) => sql.clone(),
1353    }
1354}
1355
1356fn format_enum_value(value: &str) -> String {
1357    format_qail_value_token(value, &['{', '}'])
1358}
1359
1360fn dollar_quote_qail_body(body: &str) -> String {
1361    let delimiter = if !body.contains("$$") {
1362        "$$".to_string()
1363    } else {
1364        let mut idx = 0usize;
1365        loop {
1366            let candidate = if idx == 0 {
1367                "$qail$".to_string()
1368            } else {
1369                format!("$qail{idx}$")
1370            };
1371            if !body.contains(&candidate) {
1372                break candidate;
1373            }
1374            idx = idx.saturating_add(1);
1375        }
1376    };
1377
1378    format!("{delimiter}\n{body}\n{delimiter}")
1379}
1380
1381/// Serialize a `Schema` back to a QAIL-format string.
1382pub fn to_qail_string(schema: &Schema) -> String {
1383    let mut output = String::new();
1384    output.push_str("# QAIL Schema\n\n");
1385
1386    // Extensions first (must be created before any DDL)
1387    for ext in &schema.extensions {
1388        let mut line = format!("extension {}", quote_qail_string(&ext.name));
1389        if let Some(ref s) = ext.schema {
1390            line.push_str(&format!(" schema {}", quote_qail_string(s)));
1391        }
1392        if let Some(ref v) = ext.version {
1393            line.push_str(&format!(" version {}", quote_qail_string(v)));
1394        }
1395        output.push_str(&line);
1396        output.push('\n');
1397    }
1398    if !schema.extensions.is_empty() {
1399        output.push('\n');
1400    }
1401
1402    // Enums (CREATE TYPE ... AS ENUM, must precede tables)
1403    for enum_type in &schema.enums {
1404        let values = enum_type
1405            .values
1406            .iter()
1407            .map(|v| format_enum_value(v))
1408            .collect::<Vec<_>>()
1409            .join(", ");
1410        output.push_str(&format!("enum {} {{ {} }}\n", enum_type.name, values));
1411    }
1412    if !schema.enums.is_empty() {
1413        output.push('\n');
1414    }
1415
1416    // Sequences (before tables, since columns may reference them)
1417    for seq in &schema.sequences {
1418        if seq.start.is_some()
1419            || seq.increment.is_some()
1420            || seq.min_value.is_some()
1421            || seq.max_value.is_some()
1422            || seq.cache.is_some()
1423            || seq.cycle
1424            || seq.owned_by.is_some()
1425        {
1426            let mut opts = Vec::new();
1427            if let Some(v) = seq.start {
1428                opts.push(format!("start {}", v));
1429            }
1430            if let Some(v) = seq.increment {
1431                opts.push(format!("increment {}", v));
1432            }
1433            if let Some(v) = seq.min_value {
1434                opts.push(format!("minvalue {}", v));
1435            }
1436            if let Some(v) = seq.max_value {
1437                opts.push(format!("maxvalue {}", v));
1438            }
1439            if let Some(v) = seq.cache {
1440                opts.push(format!("cache {}", v));
1441            }
1442            if seq.cycle {
1443                opts.push("cycle".to_string());
1444            }
1445            if let Some(ref o) = seq.owned_by {
1446                opts.push(format!("owned_by {}", o));
1447            }
1448            output.push_str(&format!("sequence {} {{ {} }}\n", seq.name, opts.join(" ")));
1449        } else {
1450            output.push_str(&format!("sequence {}\n", seq.name));
1451        }
1452    }
1453    if !schema.sequences.is_empty() {
1454        output.push('\n');
1455    }
1456
1457    let mut table_names: Vec<&String> = schema.tables.keys().collect();
1458    table_names.sort();
1459    for table_name in table_names {
1460        let table = &schema.tables[table_name];
1461        output.push_str(&format!("table {} {{\n", table.name));
1462        for col in &table.columns {
1463            let mut constraints: Vec<String> = Vec::new();
1464            if col.primary_key {
1465                constraints.push("primary_key".to_string());
1466            }
1467            if !col.nullable && !col.primary_key {
1468                constraints.push("not_null".to_string());
1469            }
1470            if col.unique {
1471                constraints.push("unique".to_string());
1472            }
1473            if let Some(def) = &col.default {
1474                constraints.push(format!("default {}", def));
1475            }
1476            if let Some(generated) = &col.generated {
1477                match generated {
1478                    Generated::AlwaysStored(expr) => {
1479                        constraints.push(format!("generated_stored({})", expr));
1480                    }
1481                    Generated::AlwaysIdentity => {
1482                        constraints.push("generated_identity".to_string());
1483                    }
1484                    Generated::ByDefaultIdentity => {
1485                        constraints.push("generated_by_default_identity".to_string());
1486                    }
1487                }
1488            }
1489            if let Some(ref fk) = col.foreign_key {
1490                let mut fk_str = format!("references {}({})", fk.table, fk.column);
1491                if fk.on_delete != FkAction::NoAction {
1492                    fk_str.push_str(&format!(" on_delete {}", fk_action_str(&fk.on_delete)));
1493                }
1494                if fk.on_update != FkAction::NoAction {
1495                    fk_str.push_str(&format!(" on_update {}", fk_action_str(&fk.on_update)));
1496                }
1497                match &fk.deferrable {
1498                    Deferrable::Deferrable => fk_str.push_str(" deferrable"),
1499                    Deferrable::InitiallyDeferred => fk_str.push_str(" initially_deferred"),
1500                    Deferrable::InitiallyImmediate => fk_str.push_str(" initially_immediate"),
1501                    Deferrable::NotDeferrable => {} // default, omit
1502                }
1503                constraints.push(fk_str);
1504            }
1505            if let Some(ref check) = col.check {
1506                constraints.push(format!("check({})", check_expr_str(&check.expr)));
1507                if let Some(name) = &check.name {
1508                    constraints.push(format!("check_name {}", name));
1509                }
1510            }
1511
1512            let constraint_str = if constraints.is_empty() {
1513                String::new()
1514            } else {
1515                format!(" {}", constraints.join(" "))
1516            };
1517
1518            output.push_str(&format!(
1519                "  {} {}{}\n",
1520                col.name,
1521                col.data_type.to_pg_type(),
1522                constraint_str
1523            ));
1524        }
1525        // Multi-column foreign keys
1526        for fk in &table.multi_column_fks {
1527            let mut fk_line = format!(
1528                "  foreign_key ({}) references {}({})\n",
1529                fk.columns.join(", "),
1530                fk.ref_table,
1531                fk.ref_columns.join(", ")
1532            );
1533            if fk.name.is_some()
1534                || fk.on_delete != FkAction::NoAction
1535                || fk.on_update != FkAction::NoAction
1536                || fk.deferrable != Deferrable::NotDeferrable
1537            {
1538                fk_line.pop();
1539                if let Some(name) = &fk.name {
1540                    fk_line.push_str(&format!(" constraint {}", name));
1541                }
1542                if fk.on_delete != FkAction::NoAction {
1543                    fk_line.push_str(&format!(" on_delete {}", fk_action_str(&fk.on_delete)));
1544                }
1545                if fk.on_update != FkAction::NoAction {
1546                    fk_line.push_str(&format!(" on_update {}", fk_action_str(&fk.on_update)));
1547                }
1548                match &fk.deferrable {
1549                    Deferrable::Deferrable => fk_line.push_str(" deferrable"),
1550                    Deferrable::InitiallyDeferred => fk_line.push_str(" initially_deferred"),
1551                    Deferrable::InitiallyImmediate => fk_line.push_str(" initially_immediate"),
1552                    Deferrable::NotDeferrable => {}
1553                }
1554                fk_line.push('\n');
1555            }
1556            output.push_str(&fk_line);
1557        }
1558        // RLS directives
1559        if table.enable_rls {
1560            output.push_str("  enable_rls\n");
1561        }
1562        if table.force_rls {
1563            output.push_str("  force_rls\n");
1564        }
1565        output.push_str("}\n\n");
1566    }
1567
1568    for idx in &schema.indexes {
1569        let unique = if idx.unique { "unique " } else { "" };
1570        let cols = if !idx.expressions.is_empty() {
1571            idx.expressions.join(", ")
1572        } else {
1573            idx.columns.join(", ")
1574        };
1575        let mut line = format!("{}index {} on {}", unique, idx.name, idx.table);
1576        if idx.method != IndexMethod::BTree {
1577            line.push_str(" using ");
1578            line.push_str(index_method_str(&idx.method));
1579        }
1580        line.push_str(" (");
1581        line.push_str(&cols);
1582        line.push(')');
1583        if let Some(where_clause) = &idx.where_clause {
1584            line.push_str(" where ");
1585            line.push_str(&check_expr_str(where_clause));
1586        }
1587        output.push_str(&line);
1588        output.push('\n');
1589    }
1590
1591    for hint in &schema.migrations {
1592        match hint {
1593            MigrationHint::Rename { from, to } => {
1594                output.push_str(&format!("rename {} -> {}\n", from, to));
1595            }
1596            MigrationHint::Transform { expression, target } => {
1597                output.push_str(&format!("transform {} -> {}\n", expression, target));
1598            }
1599            MigrationHint::Drop { target, confirmed } => {
1600                let confirm = if *confirmed { " confirm" } else { "" };
1601                output.push_str(&format!("drop {}{}\n", target, confirm));
1602            }
1603        }
1604    }
1605
1606    // Views
1607    for view in &schema.views {
1608        let prefix = if view.materialized {
1609            "materialized view"
1610        } else {
1611            "view"
1612        };
1613        let body = dollar_quote_qail_body(&view.query);
1614        output.push_str(&format!("{} {} {}\n\n", prefix, view.name, body));
1615    }
1616
1617    // Functions
1618    for func in &schema.functions {
1619        let args = func.args.join(", ");
1620        let volatility = func
1621            .volatility
1622            .as_deref()
1623            .filter(|v| !v.trim().is_empty())
1624            .map(|v| format!(" {}", v))
1625            .unwrap_or_default();
1626        let body = dollar_quote_qail_body(&func.body);
1627        output.push_str(&format!(
1628            "function {}({}) returns {} language {}{} {}\n\n",
1629            func.name, args, func.returns, func.language, volatility, body
1630        ));
1631    }
1632
1633    // Triggers
1634    for trigger in &schema.triggers {
1635        let mut events = Vec::new();
1636        for evt in &trigger.events {
1637            if evt.eq_ignore_ascii_case("UPDATE") && !trigger.update_columns.is_empty() {
1638                events.push(format!("UPDATE OF {}", trigger.update_columns.join(", ")));
1639            } else {
1640                events.push(evt.clone());
1641            }
1642        }
1643        output.push_str(&format!(
1644            "trigger {} on {} {} {} execute {}\n",
1645            trigger.name,
1646            trigger.table,
1647            trigger.timing.to_lowercase(),
1648            events.join(" or ").to_lowercase(),
1649            trigger.execute_function
1650        ));
1651    }
1652    if !schema.triggers.is_empty() {
1653        output.push('\n');
1654    }
1655
1656    // Policies
1657    for policy in &schema.policies {
1658        let cmd = match policy.target {
1659            PolicyTarget::All => "all",
1660            PolicyTarget::Select => "select",
1661            PolicyTarget::Insert => "insert",
1662            PolicyTarget::Update => "update",
1663            PolicyTarget::Delete => "delete",
1664        };
1665        let perm = match policy.permissiveness {
1666            PolicyPermissiveness::Permissive => "",
1667            PolicyPermissiveness::Restrictive => " restrictive",
1668        };
1669        let role_str = match &policy.role {
1670            Some(r) => format!(" to {}", r),
1671            None => String::new(),
1672        };
1673        output.push_str(&format!(
1674            "policy {} on {} for {}{}{}",
1675            policy.name, policy.table, cmd, role_str, perm
1676        ));
1677        if let Some(ref using) = policy.using {
1678            output.push_str(&format!("\n  using $$ {} $$", using));
1679        }
1680        if let Some(ref wc) = policy.with_check {
1681            output.push_str(&format!("\n  with_check $$ {} $$", wc));
1682        }
1683        output.push_str("\n\n");
1684    }
1685
1686    // Grants
1687    for grant in &schema.grants {
1688        let privs: Vec<String> = grant
1689            .privileges
1690            .iter()
1691            .map(|p| p.to_string().to_lowercase())
1692            .collect();
1693        match grant.action {
1694            GrantAction::Grant => {
1695                output.push_str(&format!(
1696                    "grant {} on {} to {}\n",
1697                    privs.join(", "),
1698                    grant.on_object,
1699                    grant.to_role
1700                ));
1701            }
1702            GrantAction::Revoke => {
1703                output.push_str(&format!(
1704                    "revoke {} on {} from {}\n",
1705                    privs.join(", "),
1706                    grant.on_object,
1707                    grant.to_role
1708                ));
1709            }
1710        }
1711    }
1712    if !schema.grants.is_empty() {
1713        output.push('\n');
1714    }
1715
1716    // Comments last (tables must exist first)
1717    for comment in &schema.comments {
1718        let text = quote_qail_string(&comment.text);
1719        match &comment.target {
1720            CommentTarget::Table(t) => {
1721                output.push_str(&format!("comment on {} {}\n", t, text));
1722            }
1723            CommentTarget::Column { table, column } => {
1724                output.push_str(&format!("comment on {}.{} {}\n", table, column, text));
1725            }
1726            CommentTarget::Raw(target) => {
1727                output.push_str(&format!("comment on {} {}\n", target, text));
1728            }
1729        }
1730    }
1731
1732    output
1733}
1734
1735fn quote_qail_string(value: &str) -> String {
1736    format!("\"{}\"", value.replace('"', "\"\""))
1737}
1738
1739/// Convert a Schema to a list of Qail commands (CREATE TABLE, CREATE INDEX).
1740/// Used by shadow migration to apply the base schema before applying diffs.
1741pub fn schema_to_commands(schema: &Schema) -> Vec<crate::ast::Qail> {
1742    use crate::ast::{Action, ColumnGeneration, Constraint, Expr, IndexDef, Qail};
1743
1744    let mut cmds = Vec::new();
1745
1746    // Topologically sort tables by FK dependencies:
1747    // referenced targets must be created before dependent tables.
1748    let mut indegree: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
1749    let mut reverse_adj: std::collections::HashMap<String, Vec<String>> =
1750        std::collections::HashMap::new();
1751
1752    for name in schema.tables.keys() {
1753        indegree.insert(name.clone(), 0);
1754    }
1755
1756    for table in schema.tables.values() {
1757        let mut deps = std::collections::HashSet::new();
1758        for col in &table.columns {
1759            if let Some(fk) = &col.foreign_key
1760                && fk.table != table.name
1761                && schema.tables.contains_key(&fk.table)
1762            {
1763                deps.insert(fk.table.clone());
1764            }
1765        }
1766        for fk in &table.multi_column_fks {
1767            if fk.ref_table != table.name && schema.tables.contains_key(&fk.ref_table) {
1768                deps.insert(fk.ref_table.clone());
1769            }
1770        }
1771
1772        indegree.insert(table.name.clone(), deps.len());
1773        for dep in deps {
1774            reverse_adj.entry(dep).or_default().push(table.name.clone());
1775        }
1776    }
1777
1778    let mut ready = std::collections::BTreeSet::new();
1779    for (name, deg) in &indegree {
1780        if *deg == 0 {
1781            ready.insert(name.clone());
1782        }
1783    }
1784
1785    let mut ordered_names: Vec<String> = Vec::with_capacity(schema.tables.len());
1786    while let Some(next) = ready.pop_first() {
1787        ordered_names.push(next.clone());
1788        if let Some(dependents) = reverse_adj.get(&next) {
1789            for dep_name in dependents {
1790                if let Some(d) = indegree.get_mut(dep_name)
1791                    && *d > 0
1792                {
1793                    *d -= 1;
1794                    if *d == 0 {
1795                        ready.insert(dep_name.clone());
1796                    }
1797                }
1798            }
1799        }
1800    }
1801
1802    // If there is an FK cycle, append remaining names in lexical order
1803    // so output is deterministic (runtime may still reject unresolved cycle).
1804    if ordered_names.len() < schema.tables.len() {
1805        let mut leftovers: Vec<String> = schema
1806            .tables
1807            .keys()
1808            .filter(|name| !ordered_names.contains(*name))
1809            .cloned()
1810            .collect();
1811        leftovers.sort();
1812        ordered_names.extend(leftovers);
1813    }
1814
1815    for table_name in ordered_names {
1816        let table = &schema.tables[&table_name];
1817        // Build columns using Expr::Def exactly like diff.rs does
1818        let columns: Vec<Expr> = table
1819            .columns
1820            .iter()
1821            .map(|col| {
1822                let mut constraints = Vec::new();
1823
1824                if col.primary_key {
1825                    constraints.push(Constraint::PrimaryKey);
1826                }
1827                if col.nullable {
1828                    constraints.push(Constraint::Nullable);
1829                }
1830                if col.unique {
1831                    constraints.push(Constraint::Unique);
1832                }
1833                if let Some(def) = &col.default {
1834                    constraints.push(Constraint::Default(def.clone()));
1835                }
1836                if let Some(ref fk) = col.foreign_key {
1837                    constraints.push(Constraint::References(foreign_key_to_sql(fk)));
1838                }
1839                if let Some(check) = &col.check {
1840                    let check_sql = check_expr_to_sql(&check.expr);
1841                    if let Some(name) = &check.name {
1842                        constraints.push(Constraint::Check(vec![format!(
1843                            "CONSTRAINT {} CHECK ({})",
1844                            name, check_sql
1845                        )]));
1846                    } else {
1847                        constraints.push(Constraint::Check(vec![check_sql]));
1848                    }
1849                }
1850                if let Some(generated) = &col.generated {
1851                    let gen_constraint = match generated {
1852                        Generated::AlwaysStored(expr) => {
1853                            Constraint::Generated(ColumnGeneration::Stored(expr.clone()))
1854                        }
1855                        Generated::AlwaysIdentity => {
1856                            Constraint::Generated(ColumnGeneration::Stored("identity".to_string()))
1857                        }
1858                        Generated::ByDefaultIdentity => Constraint::Generated(
1859                            ColumnGeneration::Stored("identity_by_default".to_string()),
1860                        ),
1861                    };
1862                    constraints.push(gen_constraint);
1863                }
1864
1865                Expr::Def {
1866                    name: col.name.clone(),
1867                    data_type: col.data_type.to_pg_type(),
1868                    constraints,
1869                }
1870            })
1871            .collect();
1872
1873        cmds.push(Qail {
1874            action: Action::Make,
1875            table: table.name.clone(),
1876            columns,
1877            ..Default::default()
1878        });
1879    }
1880
1881    // Add indexes using IndexDef like diff.rs
1882    for idx in &schema.indexes {
1883        cmds.push(Qail {
1884            action: Action::Index,
1885            table: String::new(),
1886            index_def: Some(IndexDef {
1887                name: idx.name.clone(),
1888                table: idx.table.clone(),
1889                columns: if !idx.expressions.is_empty() {
1890                    idx.expressions.clone()
1891                } else {
1892                    idx.columns.clone()
1893                },
1894                unique: idx.unique,
1895                index_type: Some(index_method_str(&idx.method).to_string()),
1896                where_clause: idx.where_clause.as_ref().map(check_expr_to_sql),
1897            }),
1898            ..Default::default()
1899        });
1900    }
1901
1902    let mut fk_table_names: Vec<&String> = schema
1903        .tables
1904        .iter()
1905        .filter(|(_, table)| !table.multi_column_fks.is_empty())
1906        .map(|(name, _)| name)
1907        .collect();
1908    fk_table_names.sort();
1909    for table_name in fk_table_names {
1910        let table = &schema.tables[table_name];
1911        for fk in &table.multi_column_fks {
1912            cmds.push(multi_column_fk_to_alter_command(&table.name, fk));
1913        }
1914    }
1915
1916    cmds
1917}
1918
1919pub(super) fn multi_column_fk_to_table_constraint(
1920    fk: &MultiColumnForeignKey,
1921) -> crate::ast::TableConstraint {
1922    crate::ast::TableConstraint::ForeignKey {
1923        name: fk.name.clone(),
1924        columns: fk.columns.clone(),
1925        ref_table: fk.ref_table.clone(),
1926        ref_columns: fk.ref_columns.clone(),
1927        on_delete: (fk.on_delete != FkAction::NoAction)
1928            .then(|| fk_action_to_sql(&fk.on_delete).to_string()),
1929        on_update: (fk.on_update != FkAction::NoAction)
1930            .then(|| fk_action_to_sql(&fk.on_update).to_string()),
1931        deferrable: deferrable_to_sql(&fk.deferrable).map(str::to_string),
1932    }
1933}
1934
1935pub(super) fn multi_column_fk_to_alter_command(
1936    table_name: &str,
1937    fk: &MultiColumnForeignKey,
1938) -> crate::ast::Qail {
1939    crate::ast::Qail {
1940        action: crate::ast::Action::Alter,
1941        table: table_name.to_string(),
1942        table_constraints: vec![multi_column_fk_to_table_constraint(fk)],
1943        ..Default::default()
1944    }
1945}
1946
1947fn fk_action_to_sql(action: &FkAction) -> &'static str {
1948    match action {
1949        FkAction::NoAction => "NO ACTION",
1950        FkAction::Cascade => "CASCADE",
1951        FkAction::SetNull => "SET NULL",
1952        FkAction::SetDefault => "SET DEFAULT",
1953        FkAction::Restrict => "RESTRICT",
1954    }
1955}
1956
1957fn deferrable_to_sql(deferrable: &Deferrable) -> Option<&'static str> {
1958    match deferrable {
1959        Deferrable::NotDeferrable => None,
1960        Deferrable::Deferrable => Some("DEFERRABLE"),
1961        Deferrable::InitiallyDeferred => Some("DEFERRABLE INITIALLY DEFERRED"),
1962        Deferrable::InitiallyImmediate => Some("DEFERRABLE INITIALLY IMMEDIATE"),
1963    }
1964}
1965
1966pub(crate) fn foreign_key_to_sql(fk: &ForeignKey) -> String {
1967    let mut target = format!("{}({})", fk.table, fk.column);
1968    if fk.on_delete != FkAction::NoAction {
1969        target.push_str(" ON DELETE ");
1970        target.push_str(fk_action_to_sql(&fk.on_delete));
1971    }
1972    if fk.on_update != FkAction::NoAction {
1973        target.push_str(" ON UPDATE ");
1974        target.push_str(fk_action_to_sql(&fk.on_update));
1975    }
1976    if let Some(def) = deferrable_to_sql(&fk.deferrable) {
1977        target.push(' ');
1978        target.push_str(def);
1979    }
1980    target
1981}
1982
1983pub(crate) fn check_expr_to_sql(expr: &CheckExpr) -> String {
1984    match expr {
1985        CheckExpr::GreaterThan { column, value } => format!("{column} > {value}"),
1986        CheckExpr::GreaterOrEqual { column, value } => format!("{column} >= {value}"),
1987        CheckExpr::LessThan { column, value } => format!("{column} < {value}"),
1988        CheckExpr::LessOrEqual { column, value } => format!("{column} <= {value}"),
1989        CheckExpr::Between { column, low, high } => format!("{column} BETWEEN {low} AND {high}"),
1990        CheckExpr::In { column, values } => {
1991            if values.len() == 1 && looks_like_raw_check_expr(&values[0]) {
1992                return values[0].clone();
1993            }
1994            let quoted = values
1995                .iter()
1996                .map(|v| format!("'{}'", v.replace('\'', "''")))
1997                .collect::<Vec<_>>()
1998                .join(", ");
1999            format!("{column} IN ({quoted})")
2000        }
2001        CheckExpr::Regex { column, pattern } => {
2002            format!("{column} ~ '{}'", pattern.replace('\'', "''"))
2003        }
2004        CheckExpr::MaxLength { column, max } => format!("char_length({column}) <= {max}"),
2005        CheckExpr::MinLength { column, min } => format!("char_length({column}) >= {min}"),
2006        CheckExpr::NotNull { column } => format!("{column} IS NOT NULL"),
2007        CheckExpr::And(left, right) => {
2008            format!(
2009                "({}) AND ({})",
2010                check_expr_to_sql(left),
2011                check_expr_to_sql(right)
2012            )
2013        }
2014        CheckExpr::Or(left, right) => {
2015            format!(
2016                "({}) OR ({})",
2017                check_expr_to_sql(left),
2018                check_expr_to_sql(right)
2019            )
2020        }
2021        CheckExpr::Not(inner) => format!("NOT ({})", check_expr_to_sql(inner)),
2022        CheckExpr::Sql(sql) => sql.clone(),
2023    }
2024}
2025
2026fn looks_like_raw_check_expr(s: &str) -> bool {
2027    s.chars()
2028        .any(|c| c.is_whitespace() || matches!(c, '<' | '>' | '=' | '!' | '(' | ')' | ':'))
2029}
2030
2031#[cfg(test)]
2032mod tests {
2033    use super::*;
2034
2035    #[test]
2036    fn test_schema_builder() {
2037        let mut schema = Schema::new();
2038
2039        let users = Table::new("users")
2040            .column(Column::new("id", ColumnType::Serial).primary_key())
2041            .column(Column::new("name", ColumnType::Text).not_null())
2042            .column(Column::new("email", ColumnType::Text).unique());
2043
2044        schema.add_table(users);
2045        schema.add_index(Index::new("idx_users_email", "users", vec!["email".into()]).unique());
2046
2047        let output = to_qail_string(&schema);
2048        assert!(output.contains("table users"));
2049        assert!(output.contains("id SERIAL primary_key"));
2050        assert!(output.contains("unique index idx_users_email"));
2051    }
2052
2053    #[test]
2054    fn test_to_qail_string_preserves_vector_index_methods() {
2055        let mut schema = Schema::new();
2056        schema.add_index(
2057            Index::new(
2058                "idx_docs_embedding_hnsw",
2059                "documents",
2060                vec!["embedding vector_l2_ops".into()],
2061            )
2062            .using(IndexMethod::Hnsw),
2063        );
2064        schema.add_index(
2065            Index::new(
2066                "idx_docs_embedding_ivfflat",
2067                "documents",
2068                vec!["embedding vector_cosine_ops".into()],
2069            )
2070            .using(IndexMethod::IvfFlat),
2071        );
2072
2073        let output = to_qail_string(&schema);
2074
2075        assert!(output.contains(
2076            "index idx_docs_embedding_hnsw on documents using hnsw (embedding vector_l2_ops)"
2077        ));
2078        assert!(output.contains(
2079            "index idx_docs_embedding_ivfflat on documents using ivfflat (embedding vector_cosine_ops)"
2080        ));
2081    }
2082
2083    #[test]
2084    fn test_migration_hints() {
2085        let mut schema = Schema::new();
2086        schema.add_hint(MigrationHint::Rename {
2087            from: "users.username".into(),
2088            to: "users.name".into(),
2089        });
2090
2091        let output = to_qail_string(&schema);
2092        assert!(output.contains("rename users.username -> users.name"));
2093    }
2094
2095    #[test]
2096    fn test_to_qail_string_includes_function_volatility() {
2097        let mut schema = Schema::new();
2098        let func = SchemaFunctionDef::new(
2099            "is_super_admin",
2100            "boolean",
2101            "BEGIN RETURN true; END;".to_string(),
2102        )
2103        .language("plpgsql")
2104        .volatility("stable");
2105        schema.add_function(func);
2106
2107        let output = to_qail_string(&schema);
2108        assert!(
2109            output.contains("function is_super_admin() returns boolean language plpgsql stable $$")
2110        );
2111    }
2112
2113    #[test]
2114    fn test_invalid_primary_key_type_strict() {
2115        let err = Column::new("data", ColumnType::Jsonb)
2116            .try_primary_key()
2117            .expect_err("JSONB should be rejected by strict PK policy");
2118        assert!(err.contains("cannot be a primary key"));
2119    }
2120
2121    #[test]
2122    fn test_invalid_primary_key_type_fail_soft() {
2123        let col = Column::new("data", ColumnType::Jsonb).primary_key();
2124        assert!(col.primary_key);
2125        assert!(!col.nullable);
2126    }
2127
2128    #[test]
2129    fn test_invalid_unique_type_strict() {
2130        let err = Column::new("data", ColumnType::Jsonb)
2131            .try_unique()
2132            .expect_err("JSONB should be rejected by strict UNIQUE policy");
2133        assert!(err.contains("cannot have UNIQUE"));
2134    }
2135
2136    #[test]
2137    fn test_invalid_unique_type_fail_soft() {
2138        let col = Column::new("data", ColumnType::Jsonb).unique();
2139        assert!(col.unique);
2140    }
2141
2142    #[test]
2143    fn test_foreign_key_valid() {
2144        let mut schema = Schema::new();
2145
2146        schema.add_table(
2147            Table::new("users").column(Column::new("id", ColumnType::Uuid).primary_key()),
2148        );
2149
2150        schema.add_table(
2151            Table::new("posts")
2152                .column(Column::new("id", ColumnType::Uuid).primary_key())
2153                .column(
2154                    Column::new("user_id", ColumnType::Uuid)
2155                        .references("users", "id")
2156                        .on_delete(FkAction::Cascade),
2157                ),
2158        );
2159
2160        // Should pass validation
2161        assert!(schema.validate().is_ok());
2162    }
2163
2164    #[test]
2165    fn test_foreign_key_invalid_table() {
2166        let mut schema = Schema::new();
2167
2168        schema.add_table(
2169            Table::new("posts")
2170                .column(Column::new("id", ColumnType::Uuid).primary_key())
2171                .column(Column::new("user_id", ColumnType::Uuid).references("nonexistent", "id")),
2172        );
2173
2174        // Should fail validation
2175        let result = schema.validate();
2176        assert!(result.is_err());
2177        assert!(result.unwrap_err()[0].contains("non-existent table"));
2178    }
2179
2180    #[test]
2181    fn test_foreign_key_invalid_column() {
2182        let mut schema = Schema::new();
2183
2184        schema.add_table(
2185            Table::new("users").column(Column::new("id", ColumnType::Uuid).primary_key()),
2186        );
2187
2188        schema.add_table(
2189            Table::new("posts")
2190                .column(Column::new("id", ColumnType::Uuid).primary_key())
2191                .column(
2192                    Column::new("user_id", ColumnType::Uuid).references("users", "wrong_column"),
2193                ),
2194        );
2195
2196        // Should fail validation
2197        let result = schema.validate();
2198        assert!(result.is_err());
2199        assert!(result.unwrap_err()[0].contains("non-existent column"));
2200    }
2201
2202    #[test]
2203    fn test_schema_to_commands_preserves_fk_actions_and_checks() {
2204        let mut schema = Schema::new();
2205        schema.add_table(
2206            Table::new("orgs").column(Column::new("id", ColumnType::Uuid).primary_key()),
2207        );
2208        schema.add_table(
2209            Table::new("users")
2210                .column(Column::new("id", ColumnType::Uuid).primary_key())
2211                .column(
2212                    Column::new("org_id", ColumnType::Uuid)
2213                        .references("orgs", "id")
2214                        .on_delete(FkAction::Cascade)
2215                        .on_update(FkAction::Restrict),
2216                )
2217                .column(
2218                    Column::new("age", ColumnType::Int).check(CheckExpr::GreaterOrEqual {
2219                        column: "age".to_string(),
2220                        value: 18,
2221                    }),
2222                ),
2223        );
2224
2225        let cmds = schema_to_commands(&schema);
2226        let users_cmd = cmds
2227            .iter()
2228            .find(|c| c.action == crate::ast::Action::Make && c.table == "users")
2229            .expect("users create command should exist");
2230        let org_id_constraints = users_cmd
2231            .columns
2232            .iter()
2233            .find_map(|e| match e {
2234                crate::ast::Expr::Def {
2235                    name, constraints, ..
2236                } if name == "org_id" => Some(constraints),
2237                _ => None,
2238            })
2239            .expect("org_id should exist");
2240        let age_constraints = users_cmd
2241            .columns
2242            .iter()
2243            .find_map(|e| match e {
2244                crate::ast::Expr::Def {
2245                    name, constraints, ..
2246                } if name == "age" => Some(constraints),
2247                _ => None,
2248            })
2249            .expect("age should exist");
2250
2251        assert!(
2252            org_id_constraints.iter().any(|c| matches!(
2253                c,
2254                crate::ast::Constraint::References(target)
2255                if target.contains("orgs(id)")
2256                    && target.contains("ON DELETE CASCADE")
2257                    && target.contains("ON UPDATE RESTRICT")
2258            )),
2259            "foreign key action clauses should be preserved"
2260        );
2261        assert!(
2262            age_constraints
2263                .iter()
2264                .any(|c| matches!(c, crate::ast::Constraint::Check(vals) if vals.len() == 1)),
2265            "check expressions should be preserved"
2266        );
2267    }
2268
2269    #[test]
2270    fn schema_to_commands_preserves_multi_column_foreign_keys() {
2271        use crate::transpiler::ToSql;
2272
2273        let mut schema = Schema::new();
2274        schema.add_table(
2275            Table::new("schedules")
2276                .column(Column::new("route_id", ColumnType::Text))
2277                .column(Column::new("schedule_id", ColumnType::Text)),
2278        );
2279        schema.add_index(
2280            Index::new(
2281                "idx_schedules_route_schedule",
2282                "schedules",
2283                vec!["route_id".to_string(), "schedule_id".to_string()],
2284            )
2285            .unique(),
2286        );
2287        schema.add_table(
2288            Table::new("trips")
2289                .column(Column::new("route_id", ColumnType::Text))
2290                .column(Column::new("schedule_id", ColumnType::Text))
2291                .foreign_key(
2292                    MultiColumnForeignKey::new(
2293                        vec!["route_id".to_string(), "schedule_id".to_string()],
2294                        "schedules",
2295                        vec!["route_id".to_string(), "schedule_id".to_string()],
2296                    )
2297                    .named("fk_trips_schedule")
2298                    .on_delete(FkAction::Cascade)
2299                    .on_update(FkAction::Restrict)
2300                    .initially_deferred(),
2301                ),
2302        );
2303
2304        let cmds = schema_to_commands(&schema);
2305        let schedules_idx = cmds
2306            .iter()
2307            .position(|c| c.action == crate::ast::Action::Make && c.table == "schedules")
2308            .expect("schedules create command should exist");
2309        let trips_idx = cmds
2310            .iter()
2311            .position(|c| c.action == crate::ast::Action::Make && c.table == "trips")
2312            .expect("trips create command should exist");
2313        let unique_idx = cmds
2314            .iter()
2315            .position(|c| {
2316                c.action == crate::ast::Action::Index
2317                    && c.index_def
2318                        .as_ref()
2319                        .is_some_and(|idx| idx.name == "idx_schedules_route_schedule")
2320            })
2321            .expect("unique index command should exist");
2322        let add_fk_idx = cmds
2323            .iter()
2324            .position(|c| c.action == crate::ast::Action::Alter && c.table == "trips")
2325            .expect("trips composite foreign key ALTER command should exist");
2326
2327        assert!(schedules_idx < unique_idx);
2328        assert!(trips_idx < unique_idx);
2329        assert!(unique_idx < add_fk_idx);
2330
2331        let trips_cmd = cmds
2332            .iter()
2333            .find(|c| c.action == crate::ast::Action::Make && c.table == "trips")
2334            .expect("trips create command should exist");
2335        assert!(
2336            trips_cmd.table_constraints.is_empty(),
2337            "composite foreign keys should not be emitted inline on CREATE TABLE"
2338        );
2339
2340        let add_fk_cmd = &cmds[add_fk_idx];
2341        assert!(
2342            add_fk_cmd
2343                .table_constraints
2344                .iter()
2345                .any(|constraint| matches!(
2346                        constraint,
2347                        crate::ast::TableConstraint::ForeignKey {
2348                            name,
2349                            columns,
2350                            ref_table,
2351                            ref_columns,
2352                            on_delete,
2353                            on_update,
2354                            deferrable,
2355                        } if columns == &["route_id", "schedule_id"]
2356                            && name.as_deref() == Some("fk_trips_schedule")
2357                            && ref_table == "schedules"
2358                            && ref_columns == &["route_id", "schedule_id"]
2359                            && on_delete.as_deref() == Some("CASCADE")
2360                            && on_update.as_deref() == Some("RESTRICT")
2361                            && deferrable.as_deref() == Some("DEFERRABLE INITIALLY DEFERRED")
2362                )),
2363            "multi-column foreign key should be represented in generated commands"
2364        );
2365
2366        let sql = add_fk_cmd.to_sql();
2367        assert!(
2368            sql.contains(
2369                "ALTER TABLE trips ADD CONSTRAINT fk_trips_schedule FOREIGN KEY (route_id, schedule_id) REFERENCES schedules(route_id, schedule_id) ON DELETE CASCADE ON UPDATE RESTRICT DEFERRABLE INITIALLY DEFERRED"
2370            ),
2371            "generated SQL should include composite foreign key, got: {sql}"
2372        );
2373    }
2374}