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
646// ============================================================================
647// Phase 9: Views, Functions, Triggers, Grants
648// ============================================================================
649
650/// A SQL view definition.
651#[derive(Debug, Clone, PartialEq)]
652pub struct ViewDef {
653    /// View name.
654    pub name: String,
655    /// Underlying SQL query.
656    pub query: String,
657    /// Whether this is a MATERIALIZED VIEW.
658    pub materialized: bool,
659}
660
661impl ViewDef {
662    /// Create a standard (non-materialized) view.
663    pub fn new(name: impl Into<String>, query: impl Into<String>) -> Self {
664        Self {
665            name: name.into(),
666            query: query.into(),
667            materialized: false,
668        }
669    }
670
671    /// Mark as MATERIALIZED VIEW.
672    pub fn materialized(mut self) -> Self {
673        self.materialized = true;
674        self
675    }
676}
677
678/// A PL/pgSQL function definition for the schema model.
679#[derive(Debug, Clone, PartialEq)]
680pub struct SchemaFunctionDef {
681    /// Function name.
682    pub name: String,
683    /// Function arguments (e.g. `"p_id uuid"`).
684    pub args: Vec<String>,
685    /// Return type.
686    pub returns: String,
687    /// Function body.
688    pub body: String,
689    /// Language (default `"plpgsql"`).
690    pub language: String,
691    /// Volatility category (VOLATILE, STABLE, IMMUTABLE).
692    pub volatility: Option<String>,
693}
694
695impl SchemaFunctionDef {
696    /// Create a new function definition.
697    pub fn new(
698        name: impl Into<String>,
699        returns: impl Into<String>,
700        body: impl Into<String>,
701    ) -> Self {
702        Self {
703            name: name.into(),
704            args: Vec::new(),
705            returns: returns.into(),
706            body: body.into(),
707            language: "plpgsql".to_string(),
708            volatility: None,
709        }
710    }
711
712    /// Set the function language.
713    pub fn language(mut self, lang: impl Into<String>) -> Self {
714        self.language = lang.into();
715        self
716    }
717
718    /// Add a function argument.
719    pub fn arg(mut self, arg: impl Into<String>) -> Self {
720        self.args.push(arg.into());
721        self
722    }
723
724    /// Set the volatility category.
725    pub fn volatility(mut self, v: impl Into<String>) -> Self {
726        self.volatility = Some(v.into());
727        self
728    }
729}
730
731/// A trigger definition for the schema model.
732#[derive(Debug, Clone, PartialEq)]
733pub struct SchemaTriggerDef {
734    /// Trigger name.
735    pub name: String,
736    /// Target table.
737    pub table: String,
738    /// Timing (BEFORE, AFTER, INSTEAD OF).
739    pub timing: String,
740    /// Events that fire the trigger (INSERT, UPDATE, DELETE).
741    pub events: Vec<String>,
742    /// Optional column list for `UPDATE OF` triggers.
743    pub update_columns: Vec<String>,
744    /// Whether the trigger fires FOR EACH ROW (vs. FOR EACH STATEMENT).
745    pub for_each_row: bool,
746    /// Function to execute.
747    pub execute_function: String,
748    /// Optional WHEN condition.
749    pub condition: Option<String>,
750}
751
752impl SchemaTriggerDef {
753    /// Create a new trigger definition.
754    pub fn new(
755        name: impl Into<String>,
756        table: impl Into<String>,
757        execute_function: impl Into<String>,
758    ) -> Self {
759        Self {
760            name: name.into(),
761            table: table.into(),
762            timing: "BEFORE".to_string(),
763            events: vec!["INSERT".to_string()],
764            update_columns: Vec::new(),
765            for_each_row: true,
766            execute_function: execute_function.into(),
767            condition: None,
768        }
769    }
770
771    /// Set the trigger timing.
772    pub fn timing(mut self, t: impl Into<String>) -> Self {
773        self.timing = t.into();
774        self
775    }
776
777    /// Set the trigger events.
778    pub fn events(mut self, evts: Vec<String>) -> Self {
779        self.events = evts;
780        self
781    }
782
783    /// Fire FOR EACH STATEMENT instead of FOR EACH ROW.
784    pub fn for_each_statement(mut self) -> Self {
785        self.for_each_row = false;
786        self
787    }
788
789    /// Set an optional WHEN condition.
790    pub fn condition(mut self, cond: impl Into<String>) -> Self {
791        self.condition = Some(cond.into());
792        self
793    }
794}
795
796/// GRANT or REVOKE permission.
797#[derive(Debug, Clone, PartialEq)]
798pub struct Grant {
799    /// GRANT or REVOKE.
800    pub action: GrantAction,
801    /// Privileges being granted/revoked.
802    pub privileges: Vec<Privilege>,
803    /// Target object (table, schema, sequence).
804    pub on_object: String,
805    /// Role receiving (or losing) the privileges.
806    pub to_role: String,
807}
808
809/// Whether a permission statement is a GRANT or REVOKE.
810#[derive(Debug, Clone, PartialEq, Default)]
811pub enum GrantAction {
812    #[default]
813    /// Grant privileges.
814    Grant,
815    /// Revoke privileges.
816    Revoke,
817}
818
819/// SQL privilege type.
820#[derive(Debug, Clone, PartialEq)]
821pub enum Privilege {
822    /// ALL PRIVILEGES.
823    All,
824    /// SELECT.
825    Select,
826    /// INSERT.
827    Insert,
828    /// UPDATE.
829    Update,
830    /// DELETE.
831    Delete,
832    /// USAGE (on schemas, sequences).
833    Usage,
834    /// EXECUTE (on functions).
835    Execute,
836}
837
838impl std::fmt::Display for Privilege {
839    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
840        match self {
841            Privilege::All => write!(f, "ALL"),
842            Privilege::Select => write!(f, "SELECT"),
843            Privilege::Insert => write!(f, "INSERT"),
844            Privilege::Update => write!(f, "UPDATE"),
845            Privilege::Delete => write!(f, "DELETE"),
846            Privilege::Usage => write!(f, "USAGE"),
847            Privilege::Execute => write!(f, "EXECUTE"),
848        }
849    }
850}
851
852impl Grant {
853    /// Create a GRANT statement.
854    pub fn new(
855        privileges: Vec<Privilege>,
856        on_object: impl Into<String>,
857        to_role: impl Into<String>,
858    ) -> Self {
859        Self {
860            action: GrantAction::Grant,
861            privileges,
862            on_object: on_object.into(),
863            to_role: to_role.into(),
864        }
865    }
866
867    /// Create a REVOKE statement.
868    pub fn revoke(
869        privileges: Vec<Privilege>,
870        on_object: impl Into<String>,
871        from_role: impl Into<String>,
872    ) -> Self {
873        Self {
874            action: GrantAction::Revoke,
875            privileges,
876            on_object: on_object.into(),
877            to_role: from_role.into(),
878        }
879    }
880}
881
882impl Schema {
883    /// Create an empty schema.
884    pub fn new() -> Self {
885        Self::default()
886    }
887
888    /// Add a table definition.
889    pub fn add_table(&mut self, table: Table) {
890        self.tables.insert(table.name.clone(), table);
891    }
892
893    /// Add an index definition.
894    pub fn add_index(&mut self, index: Index) {
895        self.indexes.push(index);
896    }
897
898    /// Add a migration hint.
899    pub fn add_hint(&mut self, hint: MigrationHint) {
900        self.migrations.push(hint);
901    }
902
903    /// Add a PostgreSQL extension.
904    pub fn add_extension(&mut self, ext: Extension) {
905        self.extensions.push(ext);
906    }
907
908    /// Add a schema comment.
909    pub fn add_comment(&mut self, comment: Comment) {
910        self.comments.push(comment);
911    }
912
913    /// Add a standalone sequence.
914    pub fn add_sequence(&mut self, seq: Sequence) {
915        self.sequences.push(seq);
916    }
917
918    /// Add a standalone ENUM type.
919    pub fn add_enum(&mut self, enum_type: EnumType) {
920        self.enums.push(enum_type);
921    }
922
923    /// Add a view definition.
924    pub fn add_view(&mut self, view: ViewDef) {
925        self.views.push(view);
926    }
927
928    /// Add a function definition.
929    pub fn add_function(&mut self, func: SchemaFunctionDef) {
930        self.functions.push(func);
931    }
932
933    /// Add a trigger definition.
934    pub fn add_trigger(&mut self, trigger: SchemaTriggerDef) {
935        self.triggers.push(trigger);
936    }
937
938    /// Add a GRANT or REVOKE.
939    pub fn add_grant(&mut self, grant: Grant) {
940        self.grants.push(grant);
941    }
942
943    /// Add an infrastructure resource declaration.
944    pub fn add_resource(&mut self, resource: ResourceDef) {
945        self.resources.push(resource);
946    }
947
948    /// Add an RLS policy definition.
949    pub fn add_policy(&mut self, policy: RlsPolicy) {
950        self.policies.push(policy);
951    }
952
953    /// Validate all foreign key references in the schema.
954    pub fn validate(&self) -> Result<(), Vec<String>> {
955        let mut errors = Vec::new();
956
957        for table in self.tables.values() {
958            for col in &table.columns {
959                if let Some(ref fk) = col.foreign_key {
960                    if !self.tables.contains_key(&fk.table) {
961                        errors.push(format!(
962                            "FK error: {}.{} references non-existent table '{}'",
963                            table.name, col.name, fk.table
964                        ));
965                    } else {
966                        let ref_table = &self.tables[&fk.table];
967                        if !ref_table.columns.iter().any(|c| c.name == fk.column) {
968                            errors.push(format!(
969                                "FK error: {}.{} references non-existent column '{}.{}'",
970                                table.name, col.name, fk.table, fk.column
971                            ));
972                        }
973                    }
974                }
975            }
976        }
977
978        if errors.is_empty() {
979            Ok(())
980        } else {
981            Err(errors)
982        }
983    }
984}
985
986impl Table {
987    /// Create a new empty table.
988    pub fn new(name: impl Into<String>) -> Self {
989        Self {
990            name: name.into(),
991            columns: Vec::new(),
992            multi_column_fks: Vec::new(),
993            enable_rls: false,
994            force_rls: false,
995        }
996    }
997
998    /// Add a column (builder pattern).
999    pub fn column(mut self, col: Column) -> Self {
1000        self.columns.push(col);
1001        self
1002    }
1003
1004    /// Add a table-level multi-column foreign key
1005    pub fn foreign_key(mut self, fk: MultiColumnForeignKey) -> Self {
1006        self.multi_column_fks.push(fk);
1007        self
1008    }
1009}
1010
1011impl Column {
1012    fn primary_key_type_error(&self) -> String {
1013        format!(
1014            "Column '{}' of type {} cannot be a primary key. \
1015             Valid PK types: scalar/indexable types \
1016             (UUID, TEXT, VARCHAR, INT, BIGINT, SERIAL, BIGSERIAL, BOOLEAN, FLOAT, DECIMAL, \
1017             TIMESTAMP, TIMESTAMPTZ, DATE, TIME, ENUM, INET, CIDR, MACADDR)",
1018            self.name,
1019            self.data_type.name()
1020        )
1021    }
1022
1023    fn unique_type_error(&self) -> String {
1024        format!(
1025            "Column '{}' of type {} cannot have UNIQUE constraint. \
1026             JSONB and BYTEA types do not support standard indexing.",
1027            self.name,
1028            self.data_type.name()
1029        )
1030    }
1031
1032    /// Create a new column with compile-time type validation.
1033    pub fn new(name: impl Into<String>, data_type: ColumnType) -> Self {
1034        Self {
1035            name: name.into(),
1036            data_type,
1037            nullable: true,
1038            primary_key: false,
1039            unique: false,
1040            default: None,
1041            foreign_key: None,
1042            check: None,
1043            generated: None,
1044        }
1045    }
1046
1047    /// Mark as NOT NULL.
1048    pub fn not_null(mut self) -> Self {
1049        self.nullable = false;
1050        self
1051    }
1052
1053    /// Set as primary key with compile-time validation.
1054    /// Validates that the column type can be a primary key.
1055    ///
1056    /// This method is fail-soft: invalid type combinations are allowed to
1057    /// continue without panicking so production callers cannot crash on
1058    /// dynamic schema input. Use [`Column::try_primary_key`] for strict mode.
1059    pub fn primary_key(mut self) -> Self {
1060        if !self.data_type.can_be_primary_key() {
1061            #[cfg(debug_assertions)]
1062            eprintln!("QAIL: {}", self.primary_key_type_error());
1063        }
1064        self.primary_key = true;
1065        self.nullable = false;
1066        self
1067    }
1068
1069    /// Strict variant of [`Column::primary_key`].
1070    ///
1071    /// Returns an error instead of panicking when type policy disallows PK.
1072    pub fn try_primary_key(mut self) -> Result<Self, String> {
1073        if !self.data_type.can_be_primary_key() {
1074            return Err(self.primary_key_type_error());
1075        }
1076        self.primary_key = true;
1077        self.nullable = false;
1078        Ok(self)
1079    }
1080
1081    /// Set as unique with compile-time validation.
1082    /// Validates that the column type supports indexing.
1083    ///
1084    /// This method is fail-soft: invalid type combinations are allowed to
1085    /// continue without panicking so production callers cannot crash on
1086    /// dynamic schema input. Use [`Column::try_unique`] for strict mode.
1087    pub fn unique(mut self) -> Self {
1088        if !self.data_type.supports_indexing() {
1089            #[cfg(debug_assertions)]
1090            eprintln!("QAIL: {}", self.unique_type_error());
1091        }
1092        self.unique = true;
1093        self
1094    }
1095
1096    /// Strict variant of [`Column::unique`].
1097    ///
1098    /// Returns an error instead of panicking when type policy disallows UNIQUE.
1099    pub fn try_unique(mut self) -> Result<Self, String> {
1100        if !self.data_type.supports_indexing() {
1101            return Err(self.unique_type_error());
1102        }
1103        self.unique = true;
1104        Ok(self)
1105    }
1106
1107    /// Set a DEFAULT value expression.
1108    pub fn default(mut self, val: impl Into<String>) -> Self {
1109        self.default = Some(val.into());
1110        self
1111    }
1112
1113    /// Add a foreign key reference to another table.
1114    /// # Example
1115    /// ```ignore
1116    /// Column::new("user_id", ColumnType::Uuid)
1117    ///     .references("users", "id")
1118    ///     .on_delete(FkAction::Cascade)
1119    /// ```
1120    pub fn references(mut self, table: &str, column: &str) -> Self {
1121        self.foreign_key = Some(ForeignKey {
1122            table: table.to_string(),
1123            column: column.to_string(),
1124            on_delete: FkAction::default(),
1125            on_update: FkAction::default(),
1126            deferrable: Deferrable::default(),
1127        });
1128        self
1129    }
1130
1131    /// Set the ON DELETE action for the foreign key.
1132    pub fn on_delete(mut self, action: FkAction) -> Self {
1133        if let Some(ref mut fk) = self.foreign_key {
1134            fk.on_delete = action;
1135        }
1136        self
1137    }
1138
1139    /// Set the ON UPDATE action for the foreign key.
1140    pub fn on_update(mut self, action: FkAction) -> Self {
1141        if let Some(ref mut fk) = self.foreign_key {
1142            fk.on_update = action;
1143        }
1144        self
1145    }
1146
1147    // ==================== Phase 1: CHECK ====================
1148
1149    /// Add a CHECK constraint (AST-native)
1150    pub fn check(mut self, expr: CheckExpr) -> Self {
1151        self.check = Some(CheckConstraint { expr, name: None });
1152        self
1153    }
1154
1155    /// Add a named CHECK constraint
1156    pub fn check_named(mut self, name: impl Into<String>, expr: CheckExpr) -> Self {
1157        self.check = Some(CheckConstraint {
1158            expr,
1159            name: Some(name.into()),
1160        });
1161        self
1162    }
1163
1164    // ==================== Phase 2: DEFERRABLE ====================
1165
1166    /// Make foreign key DEFERRABLE
1167    pub fn deferrable(mut self) -> Self {
1168        if let Some(ref mut fk) = self.foreign_key {
1169            fk.deferrable = Deferrable::Deferrable;
1170        }
1171        self
1172    }
1173
1174    /// Make foreign key DEFERRABLE INITIALLY DEFERRED
1175    pub fn initially_deferred(mut self) -> Self {
1176        if let Some(ref mut fk) = self.foreign_key {
1177            fk.deferrable = Deferrable::InitiallyDeferred;
1178        }
1179        self
1180    }
1181
1182    /// Make foreign key DEFERRABLE INITIALLY IMMEDIATE
1183    pub fn initially_immediate(mut self) -> Self {
1184        if let Some(ref mut fk) = self.foreign_key {
1185            fk.deferrable = Deferrable::InitiallyImmediate;
1186        }
1187        self
1188    }
1189
1190    // ==================== Phase 3: GENERATED ====================
1191
1192    /// GENERATED ALWAYS AS (expr) STORED
1193    pub fn generated_stored(mut self, expr: impl Into<String>) -> Self {
1194        self.generated = Some(Generated::AlwaysStored(expr.into()));
1195        self
1196    }
1197
1198    /// GENERATED ALWAYS AS IDENTITY
1199    pub fn generated_identity(mut self) -> Self {
1200        self.generated = Some(Generated::AlwaysIdentity);
1201        self
1202    }
1203
1204    /// GENERATED BY DEFAULT AS IDENTITY
1205    pub fn generated_by_default(mut self) -> Self {
1206        self.generated = Some(Generated::ByDefaultIdentity);
1207        self
1208    }
1209}
1210
1211impl Index {
1212    /// Create a new index on the given columns.
1213    pub fn new(name: impl Into<String>, table: impl Into<String>, columns: Vec<String>) -> Self {
1214        Self {
1215            name: name.into(),
1216            table: table.into(),
1217            columns,
1218            unique: false,
1219            method: IndexMethod::default(),
1220            where_clause: None,
1221            include: Vec::new(),
1222            concurrently: false,
1223            expressions: Vec::new(),
1224        }
1225    }
1226
1227    /// Create an expression index (e.g. `CREATE INDEX ON t ((lower(email)))`)
1228    pub fn expression(
1229        name: impl Into<String>,
1230        table: impl Into<String>,
1231        expressions: Vec<String>,
1232    ) -> Self {
1233        Self {
1234            name: name.into(),
1235            table: table.into(),
1236            columns: Vec::new(),
1237            unique: false,
1238            method: IndexMethod::default(),
1239            where_clause: None,
1240            include: Vec::new(),
1241            concurrently: false,
1242            expressions,
1243        }
1244    }
1245
1246    /// Mark this index as UNIQUE.
1247    pub fn unique(mut self) -> Self {
1248        self.unique = true;
1249        self
1250    }
1251
1252    // ==================== Phase 4: Advanced Index Options ====================
1253
1254    /// Set index method (USING clause)
1255    pub fn using(mut self, method: IndexMethod) -> Self {
1256        self.method = method;
1257        self
1258    }
1259
1260    /// Create a partial index with WHERE clause
1261    pub fn partial(mut self, expr: CheckExpr) -> Self {
1262        self.where_clause = Some(expr);
1263        self
1264    }
1265
1266    /// Add INCLUDE columns (covering index)
1267    pub fn include(mut self, cols: Vec<String>) -> Self {
1268        self.include = cols;
1269        self
1270    }
1271
1272    /// Create index CONCURRENTLY
1273    pub fn concurrently(mut self) -> Self {
1274        self.concurrently = true;
1275        self
1276    }
1277}
1278
1279/// Format a Schema to .qail format string.
1280/// Convert FkAction to its QAIL string representation
1281fn fk_action_str(action: &FkAction) -> &'static str {
1282    match action {
1283        FkAction::NoAction => "no_action",
1284        FkAction::Cascade => "cascade",
1285        FkAction::SetNull => "set_null",
1286        FkAction::SetDefault => "set_default",
1287        FkAction::Restrict => "restrict",
1288    }
1289}
1290
1291fn format_qail_value_token(value: &str, extra_special: &[char]) -> String {
1292    let needs_quotes = value.is_empty()
1293        || value.chars().any(|ch| {
1294            ch.is_whitespace() || matches!(ch, ',' | '\'' | '"') || extra_special.contains(&ch)
1295        });
1296
1297    if needs_quotes {
1298        format!("\"{}\"", value.replace('"', "\"\""))
1299    } else {
1300        value.to_string()
1301    }
1302}
1303
1304fn format_check_in_value(value: &str) -> String {
1305    format_qail_value_token(value, &['[', ']'])
1306}
1307
1308/// Serialize CheckExpr to QAIL check syntax
1309fn check_expr_str(expr: &CheckExpr) -> String {
1310    match expr {
1311        CheckExpr::GreaterThan { column, value } => format!("{} > {}", column, value),
1312        CheckExpr::GreaterOrEqual { column, value } => format!("{} >= {}", column, value),
1313        CheckExpr::LessThan { column, value } => format!("{} < {}", column, value),
1314        CheckExpr::LessOrEqual { column, value } => format!("{} <= {}", column, value),
1315        CheckExpr::Between { column, low, high } => format!("{} between {} {}", column, low, high),
1316        CheckExpr::In { column, values } => format!(
1317            "{} in [{}]",
1318            column,
1319            values
1320                .iter()
1321                .map(|value| format_check_in_value(value))
1322                .collect::<Vec<_>>()
1323                .join(", ")
1324        ),
1325        CheckExpr::Regex { column, pattern } => {
1326            format!("{} ~ '{}'", column, pattern.replace('\'', "''"))
1327        }
1328        CheckExpr::MaxLength { column, max } => format!("length({}) <= {}", column, max),
1329        CheckExpr::MinLength { column, min } => format!("length({}) >= {}", column, min),
1330        CheckExpr::NotNull { column } => format!("{} not_null", column),
1331        CheckExpr::And(l, r) => format!("{} and {}", check_expr_str(l), check_expr_str(r)),
1332        CheckExpr::Or(l, r) => format!("{} or {}", check_expr_str(l), check_expr_str(r)),
1333        CheckExpr::Not(e) => format!("not {}", check_expr_str(e)),
1334        CheckExpr::Sql(sql) => sql.clone(),
1335    }
1336}
1337
1338fn format_enum_value(value: &str) -> String {
1339    format_qail_value_token(value, &['{', '}'])
1340}
1341
1342fn dollar_quote_qail_body(body: &str) -> String {
1343    let delimiter = if !body.contains("$$") {
1344        "$$".to_string()
1345    } else {
1346        let mut idx = 0usize;
1347        loop {
1348            let candidate = if idx == 0 {
1349                "$qail$".to_string()
1350            } else {
1351                format!("$qail{idx}$")
1352            };
1353            if !body.contains(&candidate) {
1354                break candidate;
1355            }
1356            idx = idx.saturating_add(1);
1357        }
1358    };
1359
1360    format!("{delimiter}\n{body}\n{delimiter}")
1361}
1362
1363/// Serialize a `Schema` back to a QAIL-format string.
1364pub fn to_qail_string(schema: &Schema) -> String {
1365    let mut output = String::new();
1366    output.push_str("# QAIL Schema\n\n");
1367
1368    // Extensions first (must be created before any DDL)
1369    for ext in &schema.extensions {
1370        let mut line = format!("extension {}", quote_qail_string(&ext.name));
1371        if let Some(ref s) = ext.schema {
1372            line.push_str(&format!(" schema {}", quote_qail_string(s)));
1373        }
1374        if let Some(ref v) = ext.version {
1375            line.push_str(&format!(" version {}", quote_qail_string(v)));
1376        }
1377        output.push_str(&line);
1378        output.push('\n');
1379    }
1380    if !schema.extensions.is_empty() {
1381        output.push('\n');
1382    }
1383
1384    // Enums (CREATE TYPE ... AS ENUM, must precede tables)
1385    for enum_type in &schema.enums {
1386        let values = enum_type
1387            .values
1388            .iter()
1389            .map(|v| format_enum_value(v))
1390            .collect::<Vec<_>>()
1391            .join(", ");
1392        output.push_str(&format!("enum {} {{ {} }}\n", enum_type.name, values));
1393    }
1394    if !schema.enums.is_empty() {
1395        output.push('\n');
1396    }
1397
1398    // Sequences (before tables, since columns may reference them)
1399    for seq in &schema.sequences {
1400        if seq.start.is_some()
1401            || seq.increment.is_some()
1402            || seq.min_value.is_some()
1403            || seq.max_value.is_some()
1404            || seq.cache.is_some()
1405            || seq.cycle
1406            || seq.owned_by.is_some()
1407        {
1408            let mut opts = Vec::new();
1409            if let Some(v) = seq.start {
1410                opts.push(format!("start {}", v));
1411            }
1412            if let Some(v) = seq.increment {
1413                opts.push(format!("increment {}", v));
1414            }
1415            if let Some(v) = seq.min_value {
1416                opts.push(format!("minvalue {}", v));
1417            }
1418            if let Some(v) = seq.max_value {
1419                opts.push(format!("maxvalue {}", v));
1420            }
1421            if let Some(v) = seq.cache {
1422                opts.push(format!("cache {}", v));
1423            }
1424            if seq.cycle {
1425                opts.push("cycle".to_string());
1426            }
1427            if let Some(ref o) = seq.owned_by {
1428                opts.push(format!("owned_by {}", o));
1429            }
1430            output.push_str(&format!("sequence {} {{ {} }}\n", seq.name, opts.join(" ")));
1431        } else {
1432            output.push_str(&format!("sequence {}\n", seq.name));
1433        }
1434    }
1435    if !schema.sequences.is_empty() {
1436        output.push('\n');
1437    }
1438
1439    let mut table_names: Vec<&String> = schema.tables.keys().collect();
1440    table_names.sort();
1441    for table_name in table_names {
1442        let table = &schema.tables[table_name];
1443        output.push_str(&format!("table {} {{\n", table.name));
1444        for col in &table.columns {
1445            let mut constraints: Vec<String> = Vec::new();
1446            if col.primary_key {
1447                constraints.push("primary_key".to_string());
1448            }
1449            if !col.nullable && !col.primary_key {
1450                constraints.push("not_null".to_string());
1451            }
1452            if col.unique {
1453                constraints.push("unique".to_string());
1454            }
1455            if let Some(def) = &col.default {
1456                constraints.push(format!("default {}", def));
1457            }
1458            if let Some(generated) = &col.generated {
1459                match generated {
1460                    Generated::AlwaysStored(expr) => {
1461                        constraints.push(format!("generated_stored({})", expr));
1462                    }
1463                    Generated::AlwaysIdentity => {
1464                        constraints.push("generated_identity".to_string());
1465                    }
1466                    Generated::ByDefaultIdentity => {
1467                        constraints.push("generated_by_default_identity".to_string());
1468                    }
1469                }
1470            }
1471            if let Some(ref fk) = col.foreign_key {
1472                let mut fk_str = format!("references {}({})", fk.table, fk.column);
1473                if fk.on_delete != FkAction::NoAction {
1474                    fk_str.push_str(&format!(" on_delete {}", fk_action_str(&fk.on_delete)));
1475                }
1476                if fk.on_update != FkAction::NoAction {
1477                    fk_str.push_str(&format!(" on_update {}", fk_action_str(&fk.on_update)));
1478                }
1479                match &fk.deferrable {
1480                    Deferrable::Deferrable => fk_str.push_str(" deferrable"),
1481                    Deferrable::InitiallyDeferred => fk_str.push_str(" initially_deferred"),
1482                    Deferrable::InitiallyImmediate => fk_str.push_str(" initially_immediate"),
1483                    Deferrable::NotDeferrable => {} // default, omit
1484                }
1485                constraints.push(fk_str);
1486            }
1487            if let Some(ref check) = col.check {
1488                constraints.push(format!("check({})", check_expr_str(&check.expr)));
1489                if let Some(name) = &check.name {
1490                    constraints.push(format!("check_name {}", name));
1491                }
1492            }
1493
1494            let constraint_str = if constraints.is_empty() {
1495                String::new()
1496            } else {
1497                format!(" {}", constraints.join(" "))
1498            };
1499
1500            output.push_str(&format!(
1501                "  {} {}{}\n",
1502                col.name,
1503                col.data_type.to_pg_type(),
1504                constraint_str
1505            ));
1506        }
1507        // Multi-column foreign keys
1508        for fk in &table.multi_column_fks {
1509            output.push_str(&format!(
1510                "  foreign_key ({}) references {}({})\n",
1511                fk.columns.join(", "),
1512                fk.ref_table,
1513                fk.ref_columns.join(", ")
1514            ));
1515        }
1516        // RLS directives
1517        if table.enable_rls {
1518            output.push_str("  enable_rls\n");
1519        }
1520        if table.force_rls {
1521            output.push_str("  force_rls\n");
1522        }
1523        output.push_str("}\n\n");
1524    }
1525
1526    for idx in &schema.indexes {
1527        let unique = if idx.unique { "unique " } else { "" };
1528        let cols = if !idx.expressions.is_empty() {
1529            idx.expressions.join(", ")
1530        } else {
1531            idx.columns.join(", ")
1532        };
1533        let mut line = format!("{}index {} on {}", unique, idx.name, idx.table);
1534        if idx.method != IndexMethod::BTree {
1535            line.push_str(" using ");
1536            line.push_str(index_method_str(&idx.method));
1537        }
1538        line.push_str(" (");
1539        line.push_str(&cols);
1540        line.push(')');
1541        if let Some(where_clause) = &idx.where_clause {
1542            line.push_str(" where ");
1543            line.push_str(&check_expr_str(where_clause));
1544        }
1545        output.push_str(&line);
1546        output.push('\n');
1547    }
1548
1549    for hint in &schema.migrations {
1550        match hint {
1551            MigrationHint::Rename { from, to } => {
1552                output.push_str(&format!("rename {} -> {}\n", from, to));
1553            }
1554            MigrationHint::Transform { expression, target } => {
1555                output.push_str(&format!("transform {} -> {}\n", expression, target));
1556            }
1557            MigrationHint::Drop { target, confirmed } => {
1558                let confirm = if *confirmed { " confirm" } else { "" };
1559                output.push_str(&format!("drop {}{}\n", target, confirm));
1560            }
1561        }
1562    }
1563
1564    // Views
1565    for view in &schema.views {
1566        let prefix = if view.materialized {
1567            "materialized view"
1568        } else {
1569            "view"
1570        };
1571        let body = dollar_quote_qail_body(&view.query);
1572        output.push_str(&format!("{} {} {}\n\n", prefix, view.name, body));
1573    }
1574
1575    // Functions
1576    for func in &schema.functions {
1577        let args = func.args.join(", ");
1578        let volatility = func
1579            .volatility
1580            .as_deref()
1581            .filter(|v| !v.trim().is_empty())
1582            .map(|v| format!(" {}", v))
1583            .unwrap_or_default();
1584        let body = dollar_quote_qail_body(&func.body);
1585        output.push_str(&format!(
1586            "function {}({}) returns {} language {}{} {}\n\n",
1587            func.name, args, func.returns, func.language, volatility, body
1588        ));
1589    }
1590
1591    // Triggers
1592    for trigger in &schema.triggers {
1593        let mut events = Vec::new();
1594        for evt in &trigger.events {
1595            if evt.eq_ignore_ascii_case("UPDATE") && !trigger.update_columns.is_empty() {
1596                events.push(format!("UPDATE OF {}", trigger.update_columns.join(", ")));
1597            } else {
1598                events.push(evt.clone());
1599            }
1600        }
1601        output.push_str(&format!(
1602            "trigger {} on {} {} {} execute {}\n",
1603            trigger.name,
1604            trigger.table,
1605            trigger.timing.to_lowercase(),
1606            events.join(" or ").to_lowercase(),
1607            trigger.execute_function
1608        ));
1609    }
1610    if !schema.triggers.is_empty() {
1611        output.push('\n');
1612    }
1613
1614    // Policies
1615    for policy in &schema.policies {
1616        let cmd = match policy.target {
1617            PolicyTarget::All => "all",
1618            PolicyTarget::Select => "select",
1619            PolicyTarget::Insert => "insert",
1620            PolicyTarget::Update => "update",
1621            PolicyTarget::Delete => "delete",
1622        };
1623        let perm = match policy.permissiveness {
1624            PolicyPermissiveness::Permissive => "",
1625            PolicyPermissiveness::Restrictive => " restrictive",
1626        };
1627        let role_str = match &policy.role {
1628            Some(r) => format!(" to {}", r),
1629            None => String::new(),
1630        };
1631        output.push_str(&format!(
1632            "policy {} on {} for {}{}{}",
1633            policy.name, policy.table, cmd, role_str, perm
1634        ));
1635        if let Some(ref using) = policy.using {
1636            output.push_str(&format!("\n  using $$ {} $$", using));
1637        }
1638        if let Some(ref wc) = policy.with_check {
1639            output.push_str(&format!("\n  with_check $$ {} $$", wc));
1640        }
1641        output.push_str("\n\n");
1642    }
1643
1644    // Grants
1645    for grant in &schema.grants {
1646        let privs: Vec<String> = grant
1647            .privileges
1648            .iter()
1649            .map(|p| p.to_string().to_lowercase())
1650            .collect();
1651        match grant.action {
1652            GrantAction::Grant => {
1653                output.push_str(&format!(
1654                    "grant {} on {} to {}\n",
1655                    privs.join(", "),
1656                    grant.on_object,
1657                    grant.to_role
1658                ));
1659            }
1660            GrantAction::Revoke => {
1661                output.push_str(&format!(
1662                    "revoke {} on {} from {}\n",
1663                    privs.join(", "),
1664                    grant.on_object,
1665                    grant.to_role
1666                ));
1667            }
1668        }
1669    }
1670    if !schema.grants.is_empty() {
1671        output.push('\n');
1672    }
1673
1674    // Comments last (tables must exist first)
1675    for comment in &schema.comments {
1676        let text = quote_qail_string(&comment.text);
1677        match &comment.target {
1678            CommentTarget::Table(t) => {
1679                output.push_str(&format!("comment on {} {}\n", t, text));
1680            }
1681            CommentTarget::Column { table, column } => {
1682                output.push_str(&format!("comment on {}.{} {}\n", table, column, text));
1683            }
1684            CommentTarget::Raw(target) => {
1685                output.push_str(&format!("comment on {} {}\n", target, text));
1686            }
1687        }
1688    }
1689
1690    output
1691}
1692
1693fn quote_qail_string(value: &str) -> String {
1694    format!("\"{}\"", value.replace('"', "\"\""))
1695}
1696
1697/// Convert a Schema to a list of Qail commands (CREATE TABLE, CREATE INDEX).
1698/// Used by shadow migration to apply the base schema before applying diffs.
1699pub fn schema_to_commands(schema: &Schema) -> Vec<crate::ast::Qail> {
1700    use crate::ast::{Action, ColumnGeneration, Constraint, Expr, IndexDef, Qail};
1701
1702    let mut cmds = Vec::new();
1703
1704    // Topologically sort tables by FK dependencies:
1705    // referenced targets must be created before dependent tables.
1706    let mut indegree: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
1707    let mut reverse_adj: std::collections::HashMap<String, Vec<String>> =
1708        std::collections::HashMap::new();
1709
1710    for name in schema.tables.keys() {
1711        indegree.insert(name.clone(), 0);
1712    }
1713
1714    for table in schema.tables.values() {
1715        let mut deps = std::collections::HashSet::new();
1716        for col in &table.columns {
1717            if let Some(fk) = &col.foreign_key
1718                && fk.table != table.name
1719                && schema.tables.contains_key(&fk.table)
1720            {
1721                deps.insert(fk.table.clone());
1722            }
1723        }
1724        for fk in &table.multi_column_fks {
1725            if fk.ref_table != table.name && schema.tables.contains_key(&fk.ref_table) {
1726                deps.insert(fk.ref_table.clone());
1727            }
1728        }
1729
1730        indegree.insert(table.name.clone(), deps.len());
1731        for dep in deps {
1732            reverse_adj.entry(dep).or_default().push(table.name.clone());
1733        }
1734    }
1735
1736    let mut ready = std::collections::BTreeSet::new();
1737    for (name, deg) in &indegree {
1738        if *deg == 0 {
1739            ready.insert(name.clone());
1740        }
1741    }
1742
1743    let mut ordered_names: Vec<String> = Vec::with_capacity(schema.tables.len());
1744    while let Some(next) = ready.pop_first() {
1745        ordered_names.push(next.clone());
1746        if let Some(dependents) = reverse_adj.get(&next) {
1747            for dep_name in dependents {
1748                if let Some(d) = indegree.get_mut(dep_name)
1749                    && *d > 0
1750                {
1751                    *d -= 1;
1752                    if *d == 0 {
1753                        ready.insert(dep_name.clone());
1754                    }
1755                }
1756            }
1757        }
1758    }
1759
1760    // If there is an FK cycle, append remaining names in lexical order
1761    // so output is deterministic (runtime may still reject unresolved cycle).
1762    if ordered_names.len() < schema.tables.len() {
1763        let mut leftovers: Vec<String> = schema
1764            .tables
1765            .keys()
1766            .filter(|name| !ordered_names.contains(*name))
1767            .cloned()
1768            .collect();
1769        leftovers.sort();
1770        ordered_names.extend(leftovers);
1771    }
1772
1773    for table_name in ordered_names {
1774        let table = &schema.tables[&table_name];
1775        // Build columns using Expr::Def exactly like diff.rs does
1776        let columns: Vec<Expr> = table
1777            .columns
1778            .iter()
1779            .map(|col| {
1780                let mut constraints = Vec::new();
1781
1782                if col.primary_key {
1783                    constraints.push(Constraint::PrimaryKey);
1784                }
1785                if col.nullable {
1786                    constraints.push(Constraint::Nullable);
1787                }
1788                if col.unique {
1789                    constraints.push(Constraint::Unique);
1790                }
1791                if let Some(def) = &col.default {
1792                    constraints.push(Constraint::Default(def.clone()));
1793                }
1794                if let Some(ref fk) = col.foreign_key {
1795                    constraints.push(Constraint::References(foreign_key_to_sql(fk)));
1796                }
1797                if let Some(check) = &col.check {
1798                    let check_sql = check_expr_to_sql(&check.expr);
1799                    if let Some(name) = &check.name {
1800                        constraints.push(Constraint::Check(vec![format!(
1801                            "CONSTRAINT {} CHECK ({})",
1802                            name, check_sql
1803                        )]));
1804                    } else {
1805                        constraints.push(Constraint::Check(vec![check_sql]));
1806                    }
1807                }
1808                if let Some(generated) = &col.generated {
1809                    let gen_constraint = match generated {
1810                        Generated::AlwaysStored(expr) => {
1811                            Constraint::Generated(ColumnGeneration::Stored(expr.clone()))
1812                        }
1813                        Generated::AlwaysIdentity => {
1814                            Constraint::Generated(ColumnGeneration::Stored("identity".to_string()))
1815                        }
1816                        Generated::ByDefaultIdentity => Constraint::Generated(
1817                            ColumnGeneration::Stored("identity_by_default".to_string()),
1818                        ),
1819                    };
1820                    constraints.push(gen_constraint);
1821                }
1822
1823                Expr::Def {
1824                    name: col.name.clone(),
1825                    data_type: col.data_type.to_pg_type(),
1826                    constraints,
1827                }
1828            })
1829            .collect();
1830
1831        cmds.push(Qail {
1832            action: Action::Make,
1833            table: table.name.clone(),
1834            columns,
1835            ..Default::default()
1836        });
1837    }
1838
1839    // Add indexes using IndexDef like diff.rs
1840    for idx in &schema.indexes {
1841        cmds.push(Qail {
1842            action: Action::Index,
1843            table: String::new(),
1844            index_def: Some(IndexDef {
1845                name: idx.name.clone(),
1846                table: idx.table.clone(),
1847                columns: if !idx.expressions.is_empty() {
1848                    idx.expressions.clone()
1849                } else {
1850                    idx.columns.clone()
1851                },
1852                unique: idx.unique,
1853                index_type: Some(index_method_str(&idx.method).to_string()),
1854                where_clause: idx.where_clause.as_ref().map(check_expr_to_sql),
1855            }),
1856            ..Default::default()
1857        });
1858    }
1859
1860    let mut fk_table_names: Vec<&String> = schema
1861        .tables
1862        .iter()
1863        .filter(|(_, table)| !table.multi_column_fks.is_empty())
1864        .map(|(name, _)| name)
1865        .collect();
1866    fk_table_names.sort();
1867    for table_name in fk_table_names {
1868        let table = &schema.tables[table_name];
1869        for fk in &table.multi_column_fks {
1870            cmds.push(multi_column_fk_to_alter_command(&table.name, fk));
1871        }
1872    }
1873
1874    cmds
1875}
1876
1877pub(super) fn multi_column_fk_to_table_constraint(
1878    fk: &MultiColumnForeignKey,
1879) -> crate::ast::TableConstraint {
1880    crate::ast::TableConstraint::ForeignKey {
1881        name: fk.name.clone(),
1882        columns: fk.columns.clone(),
1883        ref_table: fk.ref_table.clone(),
1884        ref_columns: fk.ref_columns.clone(),
1885    }
1886}
1887
1888pub(super) fn multi_column_fk_to_alter_command(
1889    table_name: &str,
1890    fk: &MultiColumnForeignKey,
1891) -> crate::ast::Qail {
1892    crate::ast::Qail {
1893        action: crate::ast::Action::Alter,
1894        table: table_name.to_string(),
1895        table_constraints: vec![multi_column_fk_to_table_constraint(fk)],
1896        ..Default::default()
1897    }
1898}
1899
1900fn fk_action_to_sql(action: &FkAction) -> &'static str {
1901    match action {
1902        FkAction::NoAction => "NO ACTION",
1903        FkAction::Cascade => "CASCADE",
1904        FkAction::SetNull => "SET NULL",
1905        FkAction::SetDefault => "SET DEFAULT",
1906        FkAction::Restrict => "RESTRICT",
1907    }
1908}
1909
1910fn deferrable_to_sql(deferrable: &Deferrable) -> Option<&'static str> {
1911    match deferrable {
1912        Deferrable::NotDeferrable => None,
1913        Deferrable::Deferrable => Some("DEFERRABLE"),
1914        Deferrable::InitiallyDeferred => Some("DEFERRABLE INITIALLY DEFERRED"),
1915        Deferrable::InitiallyImmediate => Some("DEFERRABLE INITIALLY IMMEDIATE"),
1916    }
1917}
1918
1919pub(crate) fn foreign_key_to_sql(fk: &ForeignKey) -> String {
1920    let mut target = format!("{}({})", fk.table, fk.column);
1921    if fk.on_delete != FkAction::NoAction {
1922        target.push_str(" ON DELETE ");
1923        target.push_str(fk_action_to_sql(&fk.on_delete));
1924    }
1925    if fk.on_update != FkAction::NoAction {
1926        target.push_str(" ON UPDATE ");
1927        target.push_str(fk_action_to_sql(&fk.on_update));
1928    }
1929    if let Some(def) = deferrable_to_sql(&fk.deferrable) {
1930        target.push(' ');
1931        target.push_str(def);
1932    }
1933    target
1934}
1935
1936pub(crate) fn check_expr_to_sql(expr: &CheckExpr) -> String {
1937    match expr {
1938        CheckExpr::GreaterThan { column, value } => format!("{column} > {value}"),
1939        CheckExpr::GreaterOrEqual { column, value } => format!("{column} >= {value}"),
1940        CheckExpr::LessThan { column, value } => format!("{column} < {value}"),
1941        CheckExpr::LessOrEqual { column, value } => format!("{column} <= {value}"),
1942        CheckExpr::Between { column, low, high } => format!("{column} BETWEEN {low} AND {high}"),
1943        CheckExpr::In { column, values } => {
1944            if values.len() == 1 && looks_like_raw_check_expr(&values[0]) {
1945                return values[0].clone();
1946            }
1947            let quoted = values
1948                .iter()
1949                .map(|v| format!("'{}'", v.replace('\'', "''")))
1950                .collect::<Vec<_>>()
1951                .join(", ");
1952            format!("{column} IN ({quoted})")
1953        }
1954        CheckExpr::Regex { column, pattern } => {
1955            format!("{column} ~ '{}'", pattern.replace('\'', "''"))
1956        }
1957        CheckExpr::MaxLength { column, max } => format!("char_length({column}) <= {max}"),
1958        CheckExpr::MinLength { column, min } => format!("char_length({column}) >= {min}"),
1959        CheckExpr::NotNull { column } => format!("{column} IS NOT NULL"),
1960        CheckExpr::And(left, right) => {
1961            format!(
1962                "({}) AND ({})",
1963                check_expr_to_sql(left),
1964                check_expr_to_sql(right)
1965            )
1966        }
1967        CheckExpr::Or(left, right) => {
1968            format!(
1969                "({}) OR ({})",
1970                check_expr_to_sql(left),
1971                check_expr_to_sql(right)
1972            )
1973        }
1974        CheckExpr::Not(inner) => format!("NOT ({})", check_expr_to_sql(inner)),
1975        CheckExpr::Sql(sql) => sql.clone(),
1976    }
1977}
1978
1979fn looks_like_raw_check_expr(s: &str) -> bool {
1980    s.chars()
1981        .any(|c| c.is_whitespace() || matches!(c, '<' | '>' | '=' | '!' | '(' | ')' | ':'))
1982}
1983
1984#[cfg(test)]
1985mod tests {
1986    use super::*;
1987
1988    #[test]
1989    fn test_schema_builder() {
1990        let mut schema = Schema::new();
1991
1992        let users = Table::new("users")
1993            .column(Column::new("id", ColumnType::Serial).primary_key())
1994            .column(Column::new("name", ColumnType::Text).not_null())
1995            .column(Column::new("email", ColumnType::Text).unique());
1996
1997        schema.add_table(users);
1998        schema.add_index(Index::new("idx_users_email", "users", vec!["email".into()]).unique());
1999
2000        let output = to_qail_string(&schema);
2001        assert!(output.contains("table users"));
2002        assert!(output.contains("id SERIAL primary_key"));
2003        assert!(output.contains("unique index idx_users_email"));
2004    }
2005
2006    #[test]
2007    fn test_to_qail_string_preserves_vector_index_methods() {
2008        let mut schema = Schema::new();
2009        schema.add_index(
2010            Index::new(
2011                "idx_docs_embedding_hnsw",
2012                "documents",
2013                vec!["embedding vector_l2_ops".into()],
2014            )
2015            .using(IndexMethod::Hnsw),
2016        );
2017        schema.add_index(
2018            Index::new(
2019                "idx_docs_embedding_ivfflat",
2020                "documents",
2021                vec!["embedding vector_cosine_ops".into()],
2022            )
2023            .using(IndexMethod::IvfFlat),
2024        );
2025
2026        let output = to_qail_string(&schema);
2027
2028        assert!(output.contains(
2029            "index idx_docs_embedding_hnsw on documents using hnsw (embedding vector_l2_ops)"
2030        ));
2031        assert!(output.contains(
2032            "index idx_docs_embedding_ivfflat on documents using ivfflat (embedding vector_cosine_ops)"
2033        ));
2034    }
2035
2036    #[test]
2037    fn test_migration_hints() {
2038        let mut schema = Schema::new();
2039        schema.add_hint(MigrationHint::Rename {
2040            from: "users.username".into(),
2041            to: "users.name".into(),
2042        });
2043
2044        let output = to_qail_string(&schema);
2045        assert!(output.contains("rename users.username -> users.name"));
2046    }
2047
2048    #[test]
2049    fn test_to_qail_string_includes_function_volatility() {
2050        let mut schema = Schema::new();
2051        let func = SchemaFunctionDef::new(
2052            "is_super_admin",
2053            "boolean",
2054            "BEGIN RETURN true; END;".to_string(),
2055        )
2056        .language("plpgsql")
2057        .volatility("stable");
2058        schema.add_function(func);
2059
2060        let output = to_qail_string(&schema);
2061        assert!(
2062            output.contains("function is_super_admin() returns boolean language plpgsql stable $$")
2063        );
2064    }
2065
2066    #[test]
2067    fn test_invalid_primary_key_type_strict() {
2068        let err = Column::new("data", ColumnType::Jsonb)
2069            .try_primary_key()
2070            .expect_err("JSONB should be rejected by strict PK policy");
2071        assert!(err.contains("cannot be a primary key"));
2072    }
2073
2074    #[test]
2075    fn test_invalid_primary_key_type_fail_soft() {
2076        let col = Column::new("data", ColumnType::Jsonb).primary_key();
2077        assert!(col.primary_key);
2078        assert!(!col.nullable);
2079    }
2080
2081    #[test]
2082    fn test_invalid_unique_type_strict() {
2083        let err = Column::new("data", ColumnType::Jsonb)
2084            .try_unique()
2085            .expect_err("JSONB should be rejected by strict UNIQUE policy");
2086        assert!(err.contains("cannot have UNIQUE"));
2087    }
2088
2089    #[test]
2090    fn test_invalid_unique_type_fail_soft() {
2091        let col = Column::new("data", ColumnType::Jsonb).unique();
2092        assert!(col.unique);
2093    }
2094
2095    #[test]
2096    fn test_foreign_key_valid() {
2097        let mut schema = Schema::new();
2098
2099        schema.add_table(
2100            Table::new("users").column(Column::new("id", ColumnType::Uuid).primary_key()),
2101        );
2102
2103        schema.add_table(
2104            Table::new("posts")
2105                .column(Column::new("id", ColumnType::Uuid).primary_key())
2106                .column(
2107                    Column::new("user_id", ColumnType::Uuid)
2108                        .references("users", "id")
2109                        .on_delete(FkAction::Cascade),
2110                ),
2111        );
2112
2113        // Should pass validation
2114        assert!(schema.validate().is_ok());
2115    }
2116
2117    #[test]
2118    fn test_foreign_key_invalid_table() {
2119        let mut schema = Schema::new();
2120
2121        schema.add_table(
2122            Table::new("posts")
2123                .column(Column::new("id", ColumnType::Uuid).primary_key())
2124                .column(Column::new("user_id", ColumnType::Uuid).references("nonexistent", "id")),
2125        );
2126
2127        // Should fail validation
2128        let result = schema.validate();
2129        assert!(result.is_err());
2130        assert!(result.unwrap_err()[0].contains("non-existent table"));
2131    }
2132
2133    #[test]
2134    fn test_foreign_key_invalid_column() {
2135        let mut schema = Schema::new();
2136
2137        schema.add_table(
2138            Table::new("users").column(Column::new("id", ColumnType::Uuid).primary_key()),
2139        );
2140
2141        schema.add_table(
2142            Table::new("posts")
2143                .column(Column::new("id", ColumnType::Uuid).primary_key())
2144                .column(
2145                    Column::new("user_id", ColumnType::Uuid).references("users", "wrong_column"),
2146                ),
2147        );
2148
2149        // Should fail validation
2150        let result = schema.validate();
2151        assert!(result.is_err());
2152        assert!(result.unwrap_err()[0].contains("non-existent column"));
2153    }
2154
2155    #[test]
2156    fn test_schema_to_commands_preserves_fk_actions_and_checks() {
2157        let mut schema = Schema::new();
2158        schema.add_table(
2159            Table::new("orgs").column(Column::new("id", ColumnType::Uuid).primary_key()),
2160        );
2161        schema.add_table(
2162            Table::new("users")
2163                .column(Column::new("id", ColumnType::Uuid).primary_key())
2164                .column(
2165                    Column::new("org_id", ColumnType::Uuid)
2166                        .references("orgs", "id")
2167                        .on_delete(FkAction::Cascade)
2168                        .on_update(FkAction::Restrict),
2169                )
2170                .column(
2171                    Column::new("age", ColumnType::Int).check(CheckExpr::GreaterOrEqual {
2172                        column: "age".to_string(),
2173                        value: 18,
2174                    }),
2175                ),
2176        );
2177
2178        let cmds = schema_to_commands(&schema);
2179        let users_cmd = cmds
2180            .iter()
2181            .find(|c| c.action == crate::ast::Action::Make && c.table == "users")
2182            .expect("users create command should exist");
2183        let org_id_constraints = users_cmd
2184            .columns
2185            .iter()
2186            .find_map(|e| match e {
2187                crate::ast::Expr::Def {
2188                    name, constraints, ..
2189                } if name == "org_id" => Some(constraints),
2190                _ => None,
2191            })
2192            .expect("org_id should exist");
2193        let age_constraints = users_cmd
2194            .columns
2195            .iter()
2196            .find_map(|e| match e {
2197                crate::ast::Expr::Def {
2198                    name, constraints, ..
2199                } if name == "age" => Some(constraints),
2200                _ => None,
2201            })
2202            .expect("age should exist");
2203
2204        assert!(
2205            org_id_constraints.iter().any(|c| matches!(
2206                c,
2207                crate::ast::Constraint::References(target)
2208                if target.contains("orgs(id)")
2209                    && target.contains("ON DELETE CASCADE")
2210                    && target.contains("ON UPDATE RESTRICT")
2211            )),
2212            "foreign key action clauses should be preserved"
2213        );
2214        assert!(
2215            age_constraints
2216                .iter()
2217                .any(|c| matches!(c, crate::ast::Constraint::Check(vals) if vals.len() == 1)),
2218            "check expressions should be preserved"
2219        );
2220    }
2221
2222    #[test]
2223    fn schema_to_commands_preserves_multi_column_foreign_keys() {
2224        use crate::transpiler::ToSql;
2225
2226        let mut schema = Schema::new();
2227        schema.add_table(
2228            Table::new("schedules")
2229                .column(Column::new("route_id", ColumnType::Text))
2230                .column(Column::new("schedule_id", ColumnType::Text)),
2231        );
2232        schema.add_index(
2233            Index::new(
2234                "idx_schedules_route_schedule",
2235                "schedules",
2236                vec!["route_id".to_string(), "schedule_id".to_string()],
2237            )
2238            .unique(),
2239        );
2240        schema.add_table(
2241            Table::new("trips")
2242                .column(Column::new("route_id", ColumnType::Text))
2243                .column(Column::new("schedule_id", ColumnType::Text))
2244                .foreign_key(MultiColumnForeignKey::new(
2245                    vec!["route_id".to_string(), "schedule_id".to_string()],
2246                    "schedules",
2247                    vec!["route_id".to_string(), "schedule_id".to_string()],
2248                )),
2249        );
2250
2251        let cmds = schema_to_commands(&schema);
2252        let schedules_idx = cmds
2253            .iter()
2254            .position(|c| c.action == crate::ast::Action::Make && c.table == "schedules")
2255            .expect("schedules create command should exist");
2256        let trips_idx = cmds
2257            .iter()
2258            .position(|c| c.action == crate::ast::Action::Make && c.table == "trips")
2259            .expect("trips create command should exist");
2260        let unique_idx = cmds
2261            .iter()
2262            .position(|c| {
2263                c.action == crate::ast::Action::Index
2264                    && c.index_def
2265                        .as_ref()
2266                        .is_some_and(|idx| idx.name == "idx_schedules_route_schedule")
2267            })
2268            .expect("unique index command should exist");
2269        let add_fk_idx = cmds
2270            .iter()
2271            .position(|c| c.action == crate::ast::Action::Alter && c.table == "trips")
2272            .expect("trips composite foreign key ALTER command should exist");
2273
2274        assert!(schedules_idx < unique_idx);
2275        assert!(trips_idx < unique_idx);
2276        assert!(unique_idx < add_fk_idx);
2277
2278        let trips_cmd = cmds
2279            .iter()
2280            .find(|c| c.action == crate::ast::Action::Make && c.table == "trips")
2281            .expect("trips create command should exist");
2282        assert!(
2283            trips_cmd.table_constraints.is_empty(),
2284            "composite foreign keys should not be emitted inline on CREATE TABLE"
2285        );
2286
2287        let add_fk_cmd = &cmds[add_fk_idx];
2288        assert!(
2289            add_fk_cmd
2290                .table_constraints
2291                .iter()
2292                .any(|constraint| matches!(
2293                        constraint,
2294                        crate::ast::TableConstraint::ForeignKey {
2295                            columns,
2296                            ref_table,
2297                            ref_columns,
2298                            ..
2299                        } if columns == &["route_id", "schedule_id"]
2300                            && ref_table == "schedules"
2301                            && ref_columns == &["route_id", "schedule_id"]
2302                )),
2303            "multi-column foreign key should be represented in generated commands"
2304        );
2305
2306        let sql = add_fk_cmd.to_sql();
2307        assert!(
2308            sql.contains(
2309                "ALTER TABLE trips ADD FOREIGN KEY (route_id, schedule_id) REFERENCES schedules(route_id, schedule_id)"
2310            ),
2311            "generated SQL should include composite foreign key, got: {sql}"
2312        );
2313    }
2314}