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/// Binary comparison operator used by AST-native CHECK constraints.
220#[derive(Debug, Clone, Copy, PartialEq, Eq)]
221pub enum CheckComparisonOp {
222    /// Equality (`=`)
223    Equal,
224    /// Inequality (`<>`)
225    NotEqual,
226    /// Greater than (`>`)
227    GreaterThan,
228    /// Greater than or equal (`>=`)
229    GreaterOrEqual,
230    /// Less than (`<`)
231    LessThan,
232    /// Less than or equal (`<=`)
233    LessOrEqual,
234}
235
236impl CheckComparisonOp {
237    /// SQL spelling for this comparison operator.
238    pub fn as_sql_str(self) -> &'static str {
239        match self {
240            CheckComparisonOp::Equal => "=",
241            CheckComparisonOp::NotEqual => "<>",
242            CheckComparisonOp::GreaterThan => ">",
243            CheckComparisonOp::GreaterOrEqual => ">=",
244            CheckComparisonOp::LessThan => "<",
245            CheckComparisonOp::LessOrEqual => "<=",
246        }
247    }
248}
249
250/// CHECK constraint expression (AST-native where possible, raw SQL fallback when needed)
251#[derive(Debug, Clone)]
252pub enum CheckExpr {
253    /// column > value
254    GreaterThan {
255        /// Column name.
256        column: String,
257        /// Comparison value.
258        value: i64,
259    },
260    /// column >= value
261    GreaterOrEqual {
262        /// Column name.
263        column: String,
264        /// Comparison value.
265        value: i64,
266    },
267    /// column < value
268    LessThan {
269        /// Column name.
270        column: String,
271        /// Comparison value.
272        value: i64,
273    },
274    /// column <= value
275    LessOrEqual {
276        /// Column name.
277        column: String,
278        /// Comparison value.
279        value: i64,
280    },
281    /// value BETWEEN low AND high
282    Between {
283        /// Column name.
284        column: String,
285        /// Lower bound.
286        low: i64,
287        /// Upper bound.
288        high: i64,
289    },
290    /// column IN (values)
291    In {
292        /// Column name.
293        column: String,
294        /// Allowed values.
295        values: Vec<String>,
296    },
297    /// column IN (integer values)
298    InIntegers {
299        /// Column name.
300        column: String,
301        /// Allowed integer values.
302        values: Vec<i64>,
303    },
304    /// left_column op right_column
305    CompareColumns {
306        /// Left-hand column.
307        left_column: String,
308        /// Comparison operator.
309        op: CheckComparisonOp,
310        /// Right-hand column.
311        right_column: String,
312    },
313    /// column op 'text'
314    TextCompare {
315        /// Column name.
316        column: String,
317        /// Comparison operator.
318        op: CheckComparisonOp,
319        /// Text literal.
320        value: String,
321    },
322    /// column op COALESCE(other_column, 'fallback'::type)
323    CompareColumnToCoalesce {
324        /// Left-hand column.
325        left_column: String,
326        /// Comparison operator.
327        op: CheckComparisonOp,
328        /// Column used as the first COALESCE argument.
329        coalesce_column: String,
330        /// Text fallback value.
331        fallback: String,
332        /// Optional fallback type cast, such as `date`.
333        fallback_cast: Option<String>,
334    },
335    /// column = lower(btrim(column))
336    LowerTrimEquals {
337        /// Column name.
338        column: String,
339    },
340    /// column ~ pattern (regex)
341    Regex {
342        /// Column name.
343        column: String,
344        /// Regex pattern.
345        pattern: String,
346    },
347    /// LENGTH(column) <= max
348    MaxLength {
349        /// Column name.
350        column: String,
351        /// Maximum allowed length.
352        max: usize,
353    },
354    /// LENGTH(column) >= min
355    MinLength {
356        /// Column name.
357        column: String,
358        /// Minimum required length.
359        min: usize,
360    },
361    /// column IS NOT NULL
362    NotNull {
363        /// Column name.
364        column: String,
365    },
366    /// Logical AND of two expressions.
367    And(Box<CheckExpr>, Box<CheckExpr>),
368    /// Logical OR of two expressions.
369    Or(Box<CheckExpr>, Box<CheckExpr>),
370    /// Logical NOT of an expression.
371    Not(Box<CheckExpr>),
372    /// SQL boolean expression (preserved as-is).
373    Sql(String),
374}
375
376/// CHECK constraint with optional name
377#[derive(Debug, Clone)]
378pub struct CheckConstraint {
379    /// The constraint expression.
380    pub expr: CheckExpr,
381    /// Optional constraint name.
382    pub name: Option<String>,
383}
384
385// ============================================================================
386// Phase 2: DEFERRABLE Constraints
387// ============================================================================
388
389/// Constraint deferral mode
390#[derive(Debug, Clone, Default, PartialEq)]
391pub enum Deferrable {
392    #[default]
393    /// Not deferrable (default).
394    NotDeferrable,
395    /// DEFERRABLE (initially immediate).
396    Deferrable,
397    /// DEFERRABLE INITIALLY DEFERRED.
398    InitiallyDeferred,
399    /// DEFERRABLE INITIALLY IMMEDIATE.
400    InitiallyImmediate,
401}
402
403// ============================================================================
404// Phase 3: GENERATED Columns
405// ============================================================================
406
407/// GENERATED column type
408#[derive(Debug, Clone)]
409pub enum Generated {
410    /// GENERATED ALWAYS AS (expr) STORED
411    AlwaysStored(String),
412    /// GENERATED ALWAYS AS IDENTITY
413    AlwaysIdentity,
414    /// GENERATED BY DEFAULT AS IDENTITY
415    ByDefaultIdentity,
416}
417
418// ============================================================================
419// Phase 4: Advanced Index Types
420// ============================================================================
421
422/// Index method (USING clause)
423#[derive(Debug, Clone, Default, PartialEq)]
424pub enum IndexMethod {
425    #[default]
426    /// B-tree (default for most columns).
427    BTree,
428    /// Hash (equality-only lookups).
429    Hash,
430    /// GIN (full-text search, JSONB).
431    Gin,
432    /// GiST (geometric, range types).
433    Gist,
434    /// BRIN (large, naturally-ordered tables).
435    Brin,
436    /// SP-GiST (space-partitioned).
437    SpGist,
438    /// HNSW vector index (pgvector).
439    Hnsw,
440    /// IVFFlat vector index (pgvector).
441    IvfFlat,
442}
443
444pub(crate) fn index_method_str(method: &IndexMethod) -> &'static str {
445    match method {
446        IndexMethod::BTree => "btree",
447        IndexMethod::Hash => "hash",
448        IndexMethod::Gin => "gin",
449        IndexMethod::Gist => "gist",
450        IndexMethod::Brin => "brin",
451        IndexMethod::SpGist => "spgist",
452        IndexMethod::Hnsw => "hnsw",
453        IndexMethod::IvfFlat => "ivfflat",
454    }
455}
456
457// ============================================================================
458// Phase 7: Extensions, Comments, Sequences
459// ============================================================================
460
461/// PostgreSQL extension (e.g. `CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`)
462#[derive(Debug, Clone, PartialEq)]
463pub struct Extension {
464    /// Extension name (e.g. `"uuid-ossp"`).
465    pub name: String,
466    /// Target schema.
467    pub schema: Option<String>,
468    /// Pinned version.
469    pub version: Option<String>,
470}
471
472impl Extension {
473    /// Create a new extension declaration.
474    pub fn new(name: impl Into<String>) -> Self {
475        Self {
476            name: name.into(),
477            schema: None,
478            version: None,
479        }
480    }
481
482    /// Set the target schema.
483    pub fn schema(mut self, schema: impl Into<String>) -> Self {
484        self.schema = Some(schema.into());
485        self
486    }
487
488    /// Pin to a specific version.
489    pub fn version(mut self, version: impl Into<String>) -> Self {
490        self.version = Some(version.into());
491        self
492    }
493}
494
495/// COMMENT ON TABLE/COLUMN
496#[derive(Debug, Clone, PartialEq)]
497pub struct Comment {
498    /// What the comment is attached to.
499    pub target: CommentTarget,
500    /// Comment text.
501    pub text: String,
502}
503
504/// Target of a COMMENT ON statement.
505#[derive(Debug, Clone, PartialEq)]
506pub enum CommentTarget {
507    /// COMMENT ON TABLE.
508    Table(String),
509    /// COMMENT ON COLUMN.
510    Column {
511        /// Table name.
512        table: String,
513        /// Column name.
514        column: String,
515    },
516    /// COMMENT ON arbitrary object target (e.g. FUNCTION/POLICY/TYPE/CONSTRAINT).
517    Raw(String),
518}
519
520impl Comment {
521    /// Create a comment on a table.
522    pub fn on_table(table: impl Into<String>, text: impl Into<String>) -> Self {
523        Self {
524            target: CommentTarget::Table(table.into()),
525            text: text.into(),
526        }
527    }
528
529    /// Create a comment on a column.
530    pub fn on_column(
531        table: impl Into<String>,
532        column: impl Into<String>,
533        text: impl Into<String>,
534    ) -> Self {
535        Self {
536            target: CommentTarget::Column {
537                table: table.into(),
538                column: column.into(),
539            },
540            text: text.into(),
541        }
542    }
543
544    /// Create a comment on an arbitrary object target.
545    pub fn on_raw(target: impl Into<String>, text: impl Into<String>) -> Self {
546        Self {
547            target: CommentTarget::Raw(target.into()),
548            text: text.into(),
549        }
550    }
551}
552
553/// Standalone sequence (CREATE SEQUENCE)
554#[derive(Debug, Clone, PartialEq)]
555pub struct Sequence {
556    /// Sequence name.
557    pub name: String,
558    /// Data type (e.g. `"bigint"`).
559    pub data_type: Option<String>,
560    /// START WITH value.
561    pub start: Option<i64>,
562    /// INCREMENT BY value.
563    pub increment: Option<i64>,
564    /// Minimum value for the sequence (MINVALUE clause).
565    pub min_value: Option<i64>,
566    /// Maximum value for the sequence (MAXVALUE clause).
567    pub max_value: Option<i64>,
568    /// CACHE size.
569    pub cache: Option<i64>,
570    /// Whether the sequence wraps around.
571    pub cycle: bool,
572    /// OWNED BY column reference.
573    pub owned_by: Option<String>,
574}
575
576impl Sequence {
577    /// Create a new sequence.
578    pub fn new(name: impl Into<String>) -> Self {
579        Self {
580            name: name.into(),
581            data_type: None,
582            start: None,
583            increment: None,
584            min_value: None,
585            max_value: None,
586            cache: None,
587            cycle: false,
588            owned_by: None,
589        }
590    }
591
592    /// Set the START WITH value.
593    pub fn start(mut self, v: i64) -> Self {
594        self.start = Some(v);
595        self
596    }
597
598    /// Set the INCREMENT BY value.
599    pub fn increment(mut self, v: i64) -> Self {
600        self.increment = Some(v);
601        self
602    }
603
604    /// Set the MINVALUE.
605    pub fn min_value(mut self, v: i64) -> Self {
606        self.min_value = Some(v);
607        self
608    }
609
610    /// Set the MAXVALUE.
611    pub fn max_value(mut self, v: i64) -> Self {
612        self.max_value = Some(v);
613        self
614    }
615
616    /// Set the CACHE size.
617    pub fn cache(mut self, v: i64) -> Self {
618        self.cache = Some(v);
619        self
620    }
621
622    /// Enable CYCLE (wrap around at limit).
623    pub fn cycle(mut self) -> Self {
624        self.cycle = true;
625        self
626    }
627
628    /// Set the OWNED BY column reference.
629    pub fn owned_by(mut self, col: impl Into<String>) -> Self {
630        self.owned_by = Some(col.into());
631        self
632    }
633}
634
635// ============================================================================
636// Phase 8: Standalone Enums, Multi-Column FK
637// ============================================================================
638
639/// Standalone ENUM type (CREATE TYPE ... AS ENUM)
640#[derive(Debug, Clone, PartialEq)]
641pub struct EnumType {
642    /// Type name.
643    pub name: String,
644    /// Allowed values.
645    pub values: Vec<String>,
646}
647
648impl EnumType {
649    /// Create a new enum type.
650    pub fn new(name: impl Into<String>, values: Vec<String>) -> Self {
651        Self {
652            name: name.into(),
653            values,
654        }
655    }
656
657    /// Add a new value (for ALTER TYPE ADD VALUE)
658    pub fn add_value(mut self, value: impl Into<String>) -> Self {
659        self.values.push(value.into());
660        self
661    }
662}
663
664/// Table-level multi-column foreign key
665#[derive(Debug, Clone, PartialEq)]
666pub struct MultiColumnForeignKey {
667    /// Source columns.
668    pub columns: Vec<String>,
669    /// Referenced table.
670    pub ref_table: String,
671    /// Referenced columns.
672    pub ref_columns: Vec<String>,
673    /// ON DELETE action.
674    pub on_delete: FkAction,
675    /// ON UPDATE action.
676    pub on_update: FkAction,
677    /// Deferral mode.
678    pub deferrable: Deferrable,
679    /// Optional constraint name.
680    pub name: Option<String>,
681}
682
683impl MultiColumnForeignKey {
684    /// Create a new multi-column foreign key.
685    pub fn new(
686        columns: Vec<String>,
687        ref_table: impl Into<String>,
688        ref_columns: Vec<String>,
689    ) -> Self {
690        Self {
691            columns,
692            ref_table: ref_table.into(),
693            ref_columns,
694            on_delete: FkAction::default(),
695            on_update: FkAction::default(),
696            deferrable: Deferrable::default(),
697            name: None,
698        }
699    }
700
701    /// Set the ON DELETE action.
702    pub fn on_delete(mut self, action: FkAction) -> Self {
703        self.on_delete = action;
704        self
705    }
706
707    /// Set the ON UPDATE action.
708    pub fn on_update(mut self, action: FkAction) -> Self {
709        self.on_update = action;
710        self
711    }
712
713    /// Set an explicit constraint name.
714    pub fn named(mut self, name: impl Into<String>) -> Self {
715        self.name = Some(name.into());
716        self
717    }
718
719    /// Make the foreign key DEFERRABLE.
720    pub fn deferrable(mut self) -> Self {
721        self.deferrable = Deferrable::Deferrable;
722        self
723    }
724
725    /// Make the foreign key DEFERRABLE INITIALLY DEFERRED.
726    pub fn initially_deferred(mut self) -> Self {
727        self.deferrable = Deferrable::InitiallyDeferred;
728        self
729    }
730
731    /// Make the foreign key DEFERRABLE INITIALLY IMMEDIATE.
732    pub fn initially_immediate(mut self) -> Self {
733        self.deferrable = Deferrable::InitiallyImmediate;
734        self
735    }
736}
737
738// ============================================================================
739// Phase 9: Views, Functions, Triggers, Grants
740// ============================================================================
741
742/// A SQL view definition.
743#[derive(Debug, Clone, PartialEq)]
744pub struct ViewDef {
745    /// View name.
746    pub name: String,
747    /// Underlying SQL query.
748    pub query: String,
749    /// Whether this is a MATERIALIZED VIEW.
750    pub materialized: bool,
751}
752
753impl ViewDef {
754    /// Create a standard (non-materialized) view.
755    pub fn new(name: impl Into<String>, query: impl Into<String>) -> Self {
756        Self {
757            name: name.into(),
758            query: query.into(),
759            materialized: false,
760        }
761    }
762
763    /// Mark as MATERIALIZED VIEW.
764    pub fn materialized(mut self) -> Self {
765        self.materialized = true;
766        self
767    }
768}
769
770/// A PL/pgSQL function definition for the schema model.
771#[derive(Debug, Clone, PartialEq)]
772pub struct SchemaFunctionDef {
773    /// Function name.
774    pub name: String,
775    /// Function arguments (e.g. `"p_id uuid"`).
776    pub args: Vec<String>,
777    /// Return type.
778    pub returns: String,
779    /// Function body.
780    pub body: String,
781    /// Language (default `"plpgsql"`).
782    pub language: String,
783    /// Volatility category (VOLATILE, STABLE, IMMUTABLE).
784    pub volatility: Option<String>,
785}
786
787impl SchemaFunctionDef {
788    /// Create a new function definition.
789    pub fn new(
790        name: impl Into<String>,
791        returns: impl Into<String>,
792        body: impl Into<String>,
793    ) -> Self {
794        Self {
795            name: name.into(),
796            args: Vec::new(),
797            returns: returns.into(),
798            body: body.into(),
799            language: "plpgsql".to_string(),
800            volatility: None,
801        }
802    }
803
804    /// Set the function language.
805    pub fn language(mut self, lang: impl Into<String>) -> Self {
806        self.language = lang.into();
807        self
808    }
809
810    /// Add a function argument.
811    pub fn arg(mut self, arg: impl Into<String>) -> Self {
812        self.args.push(arg.into());
813        self
814    }
815
816    /// Set the volatility category.
817    pub fn volatility(mut self, v: impl Into<String>) -> Self {
818        self.volatility = Some(v.into());
819        self
820    }
821}
822
823/// A trigger definition for the schema model.
824#[derive(Debug, Clone, PartialEq)]
825pub struct SchemaTriggerDef {
826    /// Trigger name.
827    pub name: String,
828    /// Target table.
829    pub table: String,
830    /// Timing (BEFORE, AFTER, INSTEAD OF).
831    pub timing: String,
832    /// Events that fire the trigger (INSERT, UPDATE, DELETE).
833    pub events: Vec<String>,
834    /// Optional column list for `UPDATE OF` triggers.
835    pub update_columns: Vec<String>,
836    /// Whether the trigger fires FOR EACH ROW (vs. FOR EACH STATEMENT).
837    pub for_each_row: bool,
838    /// Function to execute.
839    pub execute_function: String,
840    /// Optional WHEN condition.
841    pub condition: Option<String>,
842}
843
844impl SchemaTriggerDef {
845    /// Create a new trigger definition.
846    pub fn new(
847        name: impl Into<String>,
848        table: impl Into<String>,
849        execute_function: impl Into<String>,
850    ) -> Self {
851        Self {
852            name: name.into(),
853            table: table.into(),
854            timing: "BEFORE".to_string(),
855            events: vec!["INSERT".to_string()],
856            update_columns: Vec::new(),
857            for_each_row: true,
858            execute_function: execute_function.into(),
859            condition: None,
860        }
861    }
862
863    /// Set the trigger timing.
864    pub fn timing(mut self, t: impl Into<String>) -> Self {
865        self.timing = t.into();
866        self
867    }
868
869    /// Set the trigger events.
870    pub fn events(mut self, evts: Vec<String>) -> Self {
871        self.events = evts;
872        self
873    }
874
875    /// Fire FOR EACH STATEMENT instead of FOR EACH ROW.
876    pub fn for_each_statement(mut self) -> Self {
877        self.for_each_row = false;
878        self
879    }
880
881    /// Set an optional WHEN condition.
882    pub fn condition(mut self, cond: impl Into<String>) -> Self {
883        self.condition = Some(cond.into());
884        self
885    }
886}
887
888/// GRANT or REVOKE permission.
889#[derive(Debug, Clone, PartialEq)]
890pub struct Grant {
891    /// GRANT or REVOKE.
892    pub action: GrantAction,
893    /// Privileges being granted/revoked.
894    pub privileges: Vec<Privilege>,
895    /// Target object (table, schema, sequence).
896    pub on_object: String,
897    /// Role receiving (or losing) the privileges.
898    pub to_role: String,
899}
900
901/// Whether a permission statement is a GRANT or REVOKE.
902#[derive(Debug, Clone, PartialEq, Default)]
903pub enum GrantAction {
904    #[default]
905    /// Grant privileges.
906    Grant,
907    /// Revoke privileges.
908    Revoke,
909}
910
911/// SQL privilege type.
912#[derive(Debug, Clone, PartialEq)]
913pub enum Privilege {
914    /// ALL PRIVILEGES.
915    All,
916    /// SELECT.
917    Select,
918    /// INSERT.
919    Insert,
920    /// UPDATE.
921    Update,
922    /// DELETE.
923    Delete,
924    /// USAGE (on schemas, sequences).
925    Usage,
926    /// EXECUTE (on functions).
927    Execute,
928}
929
930impl std::fmt::Display for Privilege {
931    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
932        match self {
933            Privilege::All => write!(f, "ALL"),
934            Privilege::Select => write!(f, "SELECT"),
935            Privilege::Insert => write!(f, "INSERT"),
936            Privilege::Update => write!(f, "UPDATE"),
937            Privilege::Delete => write!(f, "DELETE"),
938            Privilege::Usage => write!(f, "USAGE"),
939            Privilege::Execute => write!(f, "EXECUTE"),
940        }
941    }
942}
943
944impl Grant {
945    /// Create a GRANT statement.
946    pub fn new(
947        privileges: Vec<Privilege>,
948        on_object: impl Into<String>,
949        to_role: impl Into<String>,
950    ) -> Self {
951        Self {
952            action: GrantAction::Grant,
953            privileges,
954            on_object: on_object.into(),
955            to_role: to_role.into(),
956        }
957    }
958
959    /// Create a REVOKE statement.
960    pub fn revoke(
961        privileges: Vec<Privilege>,
962        on_object: impl Into<String>,
963        from_role: impl Into<String>,
964    ) -> Self {
965        Self {
966            action: GrantAction::Revoke,
967            privileges,
968            on_object: on_object.into(),
969            to_role: from_role.into(),
970        }
971    }
972}
973
974impl Schema {
975    /// Create an empty schema.
976    pub fn new() -> Self {
977        Self::default()
978    }
979
980    /// Add a table definition.
981    pub fn add_table(&mut self, table: Table) {
982        self.tables.insert(table.name.clone(), table);
983    }
984
985    /// Add an index definition.
986    pub fn add_index(&mut self, index: Index) {
987        self.indexes.push(index);
988    }
989
990    /// Add a migration hint.
991    pub fn add_hint(&mut self, hint: MigrationHint) {
992        self.migrations.push(hint);
993    }
994
995    /// Add a PostgreSQL extension.
996    pub fn add_extension(&mut self, ext: Extension) {
997        self.extensions.push(ext);
998    }
999
1000    /// Add a schema comment.
1001    pub fn add_comment(&mut self, comment: Comment) {
1002        self.comments.push(comment);
1003    }
1004
1005    /// Add a standalone sequence.
1006    pub fn add_sequence(&mut self, seq: Sequence) {
1007        self.sequences.push(seq);
1008    }
1009
1010    /// Add a standalone ENUM type.
1011    pub fn add_enum(&mut self, enum_type: EnumType) {
1012        self.enums.push(enum_type);
1013    }
1014
1015    /// Add a view definition.
1016    pub fn add_view(&mut self, view: ViewDef) {
1017        self.views.push(view);
1018    }
1019
1020    /// Add a function definition.
1021    pub fn add_function(&mut self, func: SchemaFunctionDef) {
1022        self.functions.push(func);
1023    }
1024
1025    /// Add a trigger definition.
1026    pub fn add_trigger(&mut self, trigger: SchemaTriggerDef) {
1027        self.triggers.push(trigger);
1028    }
1029
1030    /// Add a GRANT or REVOKE.
1031    pub fn add_grant(&mut self, grant: Grant) {
1032        self.grants.push(grant);
1033    }
1034
1035    /// Add an infrastructure resource declaration.
1036    pub fn add_resource(&mut self, resource: ResourceDef) {
1037        self.resources.push(resource);
1038    }
1039
1040    /// Add an RLS policy definition.
1041    pub fn add_policy(&mut self, policy: RlsPolicy) {
1042        self.policies.push(policy);
1043    }
1044
1045    /// Validate all foreign key references in the schema.
1046    pub fn validate(&self) -> Result<(), Vec<String>> {
1047        let mut errors = Vec::new();
1048
1049        for table in self.tables.values() {
1050            let mut seen_columns = std::collections::BTreeSet::new();
1051            for col in &table.columns {
1052                if !seen_columns.insert(col.name.as_str()) {
1053                    errors.push(format!(
1054                        "Schema error: table '{}' has duplicate column '{}'",
1055                        table.name, col.name
1056                    ));
1057                }
1058            }
1059
1060            let table_columns = table
1061                .columns
1062                .iter()
1063                .map(|column| column.name.as_str())
1064                .collect::<std::collections::BTreeSet<_>>();
1065            let mut seen_constraint_names = std::collections::BTreeSet::new();
1066
1067            for col in &table.columns {
1068                if col.primary_key && !col.data_type.can_be_primary_key() {
1069                    errors.push(format!(
1070                        "Schema error: {}.{} of type {} cannot be a primary key",
1071                        table.name,
1072                        col.name,
1073                        col.data_type.name()
1074                    ));
1075                }
1076                if col.unique && !col.data_type.supports_indexing() {
1077                    errors.push(format!(
1078                        "Schema error: {}.{} of type {} cannot have UNIQUE constraint",
1079                        table.name,
1080                        col.name,
1081                        col.data_type.name()
1082                    ));
1083                }
1084
1085                if let Some(check) = &col.check
1086                    && let Some(name) = &check.name
1087                {
1088                    if name.trim().is_empty() {
1089                        errors.push(format!(
1090                            "Constraint error: {}.{} has empty CHECK constraint name",
1091                            table.name, col.name
1092                        ));
1093                    } else if !seen_constraint_names.insert(name.as_str()) {
1094                        errors.push(format!(
1095                            "Constraint error: table '{}' has duplicate constraint name '{}'",
1096                            table.name, name
1097                        ));
1098                    }
1099                }
1100
1101                if let Some(ref fk) = col.foreign_key {
1102                    if !self.tables.contains_key(&fk.table) {
1103                        errors.push(format!(
1104                            "FK error: {}.{} references non-existent table '{}'",
1105                            table.name, col.name, fk.table
1106                        ));
1107                    } else {
1108                        let ref_table = &self.tables[&fk.table];
1109                        if !ref_table.columns.iter().any(|c| c.name == fk.column) {
1110                            errors.push(format!(
1111                                "FK error: {}.{} references non-existent column '{}.{}'",
1112                                table.name, col.name, fk.table, fk.column
1113                            ));
1114                        } else if !schema_has_unique_key(
1115                            self,
1116                            &fk.table,
1117                            std::slice::from_ref(&fk.column),
1118                        ) {
1119                            errors.push(format!(
1120                                "FK error: {}.{} references '{}.{}' without a UNIQUE or PRIMARY KEY constraint",
1121                                table.name, col.name, fk.table, fk.column
1122                            ));
1123                        }
1124                    }
1125                }
1126
1127                if let Some(check) = &col.check {
1128                    for referenced in check_expr_column_references(&check.expr) {
1129                        let referenced_column = check_expr_reference_name(referenced);
1130                        if !table_columns.contains(referenced_column.as_str()) {
1131                            errors.push(format!(
1132                                "CHECK error: {}.{} references non-existent column '{}.{}'",
1133                                table.name, col.name, table.name, referenced_column
1134                            ));
1135                        }
1136                    }
1137                }
1138            }
1139
1140            for fk in &table.multi_column_fks {
1141                if let Some(name) = &fk.name {
1142                    if name.trim().is_empty() {
1143                        errors.push(format!(
1144                            "Multi-column FK error: {} has empty constraint name",
1145                            table.name
1146                        ));
1147                    } else if !seen_constraint_names.insert(name.as_str()) {
1148                        errors.push(format!(
1149                            "Constraint error: table '{}' has duplicate constraint name '{}'",
1150                            table.name, name
1151                        ));
1152                    }
1153                }
1154
1155                if fk.columns.is_empty() {
1156                    errors.push(format!(
1157                        "Multi-column FK error: {} has no source columns",
1158                        table.name
1159                    ));
1160                }
1161                if fk.ref_columns.is_empty() {
1162                    errors.push(format!(
1163                        "Multi-column FK error: {} references '{}' with no target columns",
1164                        table.name, fk.ref_table
1165                    ));
1166                }
1167                if fk.columns.len() != fk.ref_columns.len() {
1168                    errors.push(format!(
1169                        "Multi-column FK error: {} column count {} does not match referenced column count {}",
1170                        table.name,
1171                        fk.columns.len(),
1172                        fk.ref_columns.len()
1173                    ));
1174                }
1175
1176                for source_col in &fk.columns {
1177                    if !table.columns.iter().any(|c| c.name == *source_col) {
1178                        errors.push(format!(
1179                            "Multi-column FK error: {} references non-existent source column '{}.{}'",
1180                            table.name, table.name, source_col
1181                        ));
1182                    }
1183                }
1184
1185                let Some(ref_table) = self.tables.get(&fk.ref_table) else {
1186                    errors.push(format!(
1187                        "Multi-column FK error: {} references non-existent table '{}'",
1188                        table.name, fk.ref_table
1189                    ));
1190                    continue;
1191                };
1192
1193                let mut all_ref_columns_exist = true;
1194                for ref_col in &fk.ref_columns {
1195                    if !ref_table.columns.iter().any(|c| c.name == *ref_col) {
1196                        all_ref_columns_exist = false;
1197                        errors.push(format!(
1198                            "Multi-column FK error: {} references non-existent column '{}.{}'",
1199                            table.name, fk.ref_table, ref_col
1200                        ));
1201                    }
1202                }
1203
1204                if all_ref_columns_exist
1205                    && !fk.ref_columns.is_empty()
1206                    && fk.columns.len() == fk.ref_columns.len()
1207                    && !schema_has_unique_key(self, &fk.ref_table, &fk.ref_columns)
1208                {
1209                    errors.push(format!(
1210                        "Multi-column FK error: {} references '{}({})' without a matching UNIQUE or PRIMARY KEY constraint",
1211                        table.name,
1212                        fk.ref_table,
1213                        fk.ref_columns.join(", ")
1214                    ));
1215                }
1216            }
1217        }
1218
1219        let mut seen_index_names = std::collections::BTreeSet::new();
1220        for index in &self.indexes {
1221            if !seen_index_names.insert(index.name.as_str()) {
1222                errors.push(format!(
1223                    "Index error: duplicate index name '{}'",
1224                    index.name
1225                ));
1226            }
1227
1228            let Some(table) = self.tables.get(&index.table) else {
1229                errors.push(format!(
1230                    "Index error: {} references non-existent table '{}'",
1231                    index.name, index.table
1232                ));
1233                continue;
1234            };
1235
1236            if index.columns.is_empty() && index.expressions.is_empty() {
1237                errors.push(format!(
1238                    "Index error: {} must define at least one column or expression",
1239                    index.name
1240                ));
1241            }
1242            if !index.columns.is_empty() && !index.expressions.is_empty() {
1243                errors.push(format!(
1244                    "Index error: {} cannot mix columns and expressions",
1245                    index.name
1246                ));
1247            }
1248
1249            for column in &index.columns {
1250                if column.trim().is_empty() {
1251                    errors.push(format!("Index error: {} has empty column", index.name));
1252                    continue;
1253                }
1254                let Some(column_name) = index_column_reference_name(column) else {
1255                    continue;
1256                };
1257                if !table.columns.iter().any(|c| c.name == column_name) {
1258                    errors.push(format!(
1259                        "Index error: {} references non-existent column '{}.{}'",
1260                        index.name, index.table, column_name
1261                    ));
1262                }
1263            }
1264
1265            for expression in &index.expressions {
1266                if expression.trim().is_empty() {
1267                    errors.push(format!("Index error: {} has empty expression", index.name));
1268                }
1269            }
1270
1271            for include_column in &index.include {
1272                let Some(column_name) = index_column_reference_name(include_column) else {
1273                    errors.push(format!(
1274                        "Index error: {} has invalid INCLUDE column '{}'",
1275                        index.name, include_column
1276                    ));
1277                    continue;
1278                };
1279                if !table.columns.iter().any(|c| c.name == column_name) {
1280                    errors.push(format!(
1281                        "Index error: {} references non-existent INCLUDE column '{}.{}'",
1282                        index.name, index.table, column_name
1283                    ));
1284                }
1285            }
1286
1287            if let Some(where_clause) = &index.where_clause {
1288                for referenced in check_expr_column_references(where_clause) {
1289                    let referenced_column = check_expr_reference_name(referenced);
1290                    if !table.columns.iter().any(|c| c.name == referenced_column) {
1291                        errors.push(format!(
1292                            "Index error: {} WHERE references non-existent column '{}.{}'",
1293                            index.name, index.table, referenced_column
1294                        ));
1295                    }
1296                }
1297            }
1298        }
1299
1300        if errors.is_empty() {
1301            Ok(())
1302        } else {
1303            Err(errors)
1304        }
1305    }
1306}
1307
1308fn check_expr_column_references(expr: &CheckExpr) -> Vec<&str> {
1309    let mut refs = Vec::new();
1310    collect_check_expr_column_references(expr, &mut refs);
1311    refs.sort_unstable();
1312    refs.dedup();
1313    refs
1314}
1315
1316fn collect_check_expr_column_references<'a>(expr: &'a CheckExpr, refs: &mut Vec<&'a str>) {
1317    match expr {
1318        CheckExpr::GreaterThan { column, .. }
1319        | CheckExpr::GreaterOrEqual { column, .. }
1320        | CheckExpr::LessThan { column, .. }
1321        | CheckExpr::LessOrEqual { column, .. }
1322        | CheckExpr::Between { column, .. }
1323        | CheckExpr::In { column, .. }
1324        | CheckExpr::InIntegers { column, .. }
1325        | CheckExpr::TextCompare { column, .. }
1326        | CheckExpr::LowerTrimEquals { column }
1327        | CheckExpr::Regex { column, .. }
1328        | CheckExpr::MaxLength { column, .. }
1329        | CheckExpr::MinLength { column, .. }
1330        | CheckExpr::NotNull { column } => refs.push(column),
1331        CheckExpr::CompareColumns {
1332            left_column,
1333            right_column,
1334            ..
1335        } => {
1336            refs.push(left_column);
1337            refs.push(right_column);
1338        }
1339        CheckExpr::CompareColumnToCoalesce {
1340            left_column,
1341            coalesce_column,
1342            ..
1343        } => {
1344            refs.push(left_column);
1345            refs.push(coalesce_column);
1346        }
1347        CheckExpr::And(left, right) | CheckExpr::Or(left, right) => {
1348            collect_check_expr_column_references(left, refs);
1349            collect_check_expr_column_references(right, refs);
1350        }
1351        CheckExpr::Not(inner) => collect_check_expr_column_references(inner, refs),
1352        CheckExpr::Sql(_) => {}
1353    }
1354}
1355
1356fn check_expr_reference_name(reference: &str) -> String {
1357    let trimmed = reference.trim();
1358    let unqualified = trimmed.rsplit('.').next().unwrap_or(trimmed);
1359    unquote_identifier(unqualified)
1360}
1361
1362fn schema_has_unique_key(schema: &Schema, table_name: &str, columns: &[String]) -> bool {
1363    if columns.is_empty() {
1364        return false;
1365    }
1366
1367    let Some(table) = schema.tables.get(table_name) else {
1368        return false;
1369    };
1370
1371    if columns.len() == 1
1372        && table
1373            .columns
1374            .iter()
1375            .any(|column| column.name == columns[0] && (column.primary_key || column.unique))
1376    {
1377        return true;
1378    }
1379
1380    schema.indexes.iter().any(|index| {
1381        index.table == table_name
1382            && index.unique
1383            && index.where_clause.is_none()
1384            && index.expressions.is_empty()
1385            && index.columns.len() == columns.len()
1386            && index
1387                .columns
1388                .iter()
1389                .filter_map(|column| index_column_reference_name(column))
1390                .eq(columns.iter().cloned())
1391    })
1392}
1393
1394fn index_column_reference_name(fragment: &str) -> Option<String> {
1395    let fragment = fragment.trim();
1396    if fragment.is_empty() || fragment.contains('(') || fragment.contains("->") {
1397        return None;
1398    }
1399
1400    let token = first_index_column_token(fragment)?;
1401    let unqualified = token.rsplit('.').next().unwrap_or(token);
1402    Some(unquote_identifier(unqualified))
1403}
1404
1405fn first_index_column_token(fragment: &str) -> Option<&str> {
1406    let fragment = fragment.trim_start();
1407    if fragment.starts_with('"') {
1408        let mut escaped = false;
1409        for (idx, ch) in fragment.char_indices().skip(1) {
1410            if escaped {
1411                escaped = false;
1412                continue;
1413            }
1414            if ch == '"' {
1415                if fragment[idx + ch.len_utf8()..].starts_with('"') {
1416                    escaped = true;
1417                    continue;
1418                }
1419                return Some(&fragment[..=idx]);
1420            }
1421        }
1422        return None;
1423    }
1424
1425    let end = fragment
1426        .find(|ch: char| ch.is_whitespace() || ch == '-' || ch == '>')
1427        .unwrap_or(fragment.len());
1428    (end > 0).then_some(&fragment[..end])
1429}
1430
1431fn unquote_identifier(identifier: &str) -> String {
1432    identifier
1433        .strip_prefix('"')
1434        .and_then(|s| s.strip_suffix('"'))
1435        .map(|s| s.replace("\"\"", "\""))
1436        .unwrap_or_else(|| identifier.to_string())
1437}
1438
1439impl Table {
1440    /// Create a new empty table.
1441    pub fn new(name: impl Into<String>) -> Self {
1442        Self {
1443            name: name.into(),
1444            columns: Vec::new(),
1445            multi_column_fks: Vec::new(),
1446            enable_rls: false,
1447            force_rls: false,
1448        }
1449    }
1450
1451    /// Add a column (builder pattern).
1452    pub fn column(mut self, col: Column) -> Self {
1453        self.columns.push(col);
1454        self
1455    }
1456
1457    /// Add a table-level multi-column foreign key
1458    pub fn foreign_key(mut self, fk: MultiColumnForeignKey) -> Self {
1459        self.multi_column_fks.push(fk);
1460        self
1461    }
1462}
1463
1464impl Column {
1465    fn primary_key_type_error(&self) -> String {
1466        format!(
1467            "Column '{}' of type {} cannot be a primary key. \
1468             Valid PK types: scalar/indexable types \
1469             (UUID, TEXT, VARCHAR, INT, BIGINT, SERIAL, BIGSERIAL, BOOLEAN, FLOAT, DECIMAL, \
1470             TIMESTAMP, TIMESTAMPTZ, DATE, TIME, ENUM, INET, CIDR, MACADDR)",
1471            self.name,
1472            self.data_type.name()
1473        )
1474    }
1475
1476    fn unique_type_error(&self) -> String {
1477        format!(
1478            "Column '{}' of type {} cannot have UNIQUE constraint. \
1479             JSONB and BYTEA types do not support standard indexing.",
1480            self.name,
1481            self.data_type.name()
1482        )
1483    }
1484
1485    /// Create a new column with compile-time type validation.
1486    pub fn new(name: impl Into<String>, data_type: ColumnType) -> Self {
1487        Self {
1488            name: name.into(),
1489            data_type,
1490            nullable: true,
1491            primary_key: false,
1492            unique: false,
1493            default: None,
1494            foreign_key: None,
1495            check: None,
1496            generated: None,
1497        }
1498    }
1499
1500    /// Mark as NOT NULL.
1501    pub fn not_null(mut self) -> Self {
1502        self.nullable = false;
1503        self
1504    }
1505
1506    /// Set as primary key with compile-time validation.
1507    /// Validates that the column type can be a primary key.
1508    ///
1509    /// This method is fail-soft: invalid type combinations are allowed to
1510    /// continue without panicking so production callers cannot crash on
1511    /// dynamic schema input. Use [`Column::try_primary_key`] for strict mode.
1512    pub fn primary_key(mut self) -> Self {
1513        if !self.data_type.can_be_primary_key() {
1514            #[cfg(debug_assertions)]
1515            eprintln!("QAIL: {}", self.primary_key_type_error());
1516        }
1517        self.primary_key = true;
1518        self.nullable = false;
1519        self
1520    }
1521
1522    /// Strict variant of [`Column::primary_key`].
1523    ///
1524    /// Returns an error instead of panicking when type policy disallows PK.
1525    pub fn try_primary_key(mut self) -> Result<Self, String> {
1526        if !self.data_type.can_be_primary_key() {
1527            return Err(self.primary_key_type_error());
1528        }
1529        self.primary_key = true;
1530        self.nullable = false;
1531        Ok(self)
1532    }
1533
1534    /// Set as unique with compile-time validation.
1535    /// Validates that the column type supports indexing.
1536    ///
1537    /// This method is fail-soft: invalid type combinations are allowed to
1538    /// continue without panicking so production callers cannot crash on
1539    /// dynamic schema input. Use [`Column::try_unique`] for strict mode.
1540    pub fn unique(mut self) -> Self {
1541        if !self.data_type.supports_indexing() {
1542            #[cfg(debug_assertions)]
1543            eprintln!("QAIL: {}", self.unique_type_error());
1544        }
1545        self.unique = true;
1546        self
1547    }
1548
1549    /// Strict variant of [`Column::unique`].
1550    ///
1551    /// Returns an error instead of panicking when type policy disallows UNIQUE.
1552    pub fn try_unique(mut self) -> Result<Self, String> {
1553        if !self.data_type.supports_indexing() {
1554            return Err(self.unique_type_error());
1555        }
1556        self.unique = true;
1557        Ok(self)
1558    }
1559
1560    /// Set a DEFAULT value expression.
1561    pub fn default(mut self, val: impl Into<String>) -> Self {
1562        self.default = Some(val.into());
1563        self
1564    }
1565
1566    /// Add a foreign key reference to another table.
1567    /// # Example
1568    /// ```ignore
1569    /// Column::new("user_id", ColumnType::Uuid)
1570    ///     .references("users", "id")
1571    ///     .on_delete(FkAction::Cascade)
1572    /// ```
1573    pub fn references(mut self, table: &str, column: &str) -> Self {
1574        self.foreign_key = Some(ForeignKey {
1575            table: table.to_string(),
1576            column: column.to_string(),
1577            on_delete: FkAction::default(),
1578            on_update: FkAction::default(),
1579            deferrable: Deferrable::default(),
1580        });
1581        self
1582    }
1583
1584    /// Set the ON DELETE action for the foreign key.
1585    pub fn on_delete(mut self, action: FkAction) -> Self {
1586        if let Some(ref mut fk) = self.foreign_key {
1587            fk.on_delete = action;
1588        }
1589        self
1590    }
1591
1592    /// Set the ON UPDATE action for the foreign key.
1593    pub fn on_update(mut self, action: FkAction) -> Self {
1594        if let Some(ref mut fk) = self.foreign_key {
1595            fk.on_update = action;
1596        }
1597        self
1598    }
1599
1600    // ==================== Phase 1: CHECK ====================
1601
1602    /// Add a CHECK constraint (AST-native)
1603    pub fn check(mut self, expr: CheckExpr) -> Self {
1604        self.check = Some(CheckConstraint { expr, name: None });
1605        self
1606    }
1607
1608    /// Add a named CHECK constraint
1609    pub fn check_named(mut self, name: impl Into<String>, expr: CheckExpr) -> Self {
1610        self.check = Some(CheckConstraint {
1611            expr,
1612            name: Some(name.into()),
1613        });
1614        self
1615    }
1616
1617    // ==================== Phase 2: DEFERRABLE ====================
1618
1619    /// Make foreign key DEFERRABLE
1620    pub fn deferrable(mut self) -> Self {
1621        if let Some(ref mut fk) = self.foreign_key {
1622            fk.deferrable = Deferrable::Deferrable;
1623        }
1624        self
1625    }
1626
1627    /// Make foreign key DEFERRABLE INITIALLY DEFERRED
1628    pub fn initially_deferred(mut self) -> Self {
1629        if let Some(ref mut fk) = self.foreign_key {
1630            fk.deferrable = Deferrable::InitiallyDeferred;
1631        }
1632        self
1633    }
1634
1635    /// Make foreign key DEFERRABLE INITIALLY IMMEDIATE
1636    pub fn initially_immediate(mut self) -> Self {
1637        if let Some(ref mut fk) = self.foreign_key {
1638            fk.deferrable = Deferrable::InitiallyImmediate;
1639        }
1640        self
1641    }
1642
1643    // ==================== Phase 3: GENERATED ====================
1644
1645    /// GENERATED ALWAYS AS (expr) STORED
1646    pub fn generated_stored(mut self, expr: impl Into<String>) -> Self {
1647        self.generated = Some(Generated::AlwaysStored(expr.into()));
1648        self
1649    }
1650
1651    /// GENERATED ALWAYS AS IDENTITY
1652    pub fn generated_identity(mut self) -> Self {
1653        self.generated = Some(Generated::AlwaysIdentity);
1654        self
1655    }
1656
1657    /// GENERATED BY DEFAULT AS IDENTITY
1658    pub fn generated_by_default(mut self) -> Self {
1659        self.generated = Some(Generated::ByDefaultIdentity);
1660        self
1661    }
1662}
1663
1664impl Index {
1665    /// Create a new index on the given columns.
1666    pub fn new(name: impl Into<String>, table: impl Into<String>, columns: Vec<String>) -> Self {
1667        Self {
1668            name: name.into(),
1669            table: table.into(),
1670            columns,
1671            unique: false,
1672            method: IndexMethod::default(),
1673            where_clause: None,
1674            include: Vec::new(),
1675            concurrently: false,
1676            expressions: Vec::new(),
1677        }
1678    }
1679
1680    /// Create an expression index (e.g. `CREATE INDEX ON t ((lower(email)))`)
1681    pub fn expression(
1682        name: impl Into<String>,
1683        table: impl Into<String>,
1684        expressions: Vec<String>,
1685    ) -> Self {
1686        Self {
1687            name: name.into(),
1688            table: table.into(),
1689            columns: Vec::new(),
1690            unique: false,
1691            method: IndexMethod::default(),
1692            where_clause: None,
1693            include: Vec::new(),
1694            concurrently: false,
1695            expressions,
1696        }
1697    }
1698
1699    /// Mark this index as UNIQUE.
1700    pub fn unique(mut self) -> Self {
1701        self.unique = true;
1702        self
1703    }
1704
1705    // ==================== Phase 4: Advanced Index Options ====================
1706
1707    /// Set index method (USING clause)
1708    pub fn using(mut self, method: IndexMethod) -> Self {
1709        self.method = method;
1710        self
1711    }
1712
1713    /// Create a partial index with WHERE clause
1714    pub fn partial(mut self, expr: CheckExpr) -> Self {
1715        self.where_clause = Some(expr);
1716        self
1717    }
1718
1719    /// Add INCLUDE columns (covering index)
1720    pub fn include(mut self, cols: Vec<String>) -> Self {
1721        self.include = cols;
1722        self
1723    }
1724
1725    /// Create index CONCURRENTLY
1726    pub fn concurrently(mut self) -> Self {
1727        self.concurrently = true;
1728        self
1729    }
1730}
1731
1732/// Format a Schema to .qail format string.
1733/// Convert FkAction to its QAIL string representation
1734fn fk_action_str(action: &FkAction) -> &'static str {
1735    match action {
1736        FkAction::NoAction => "no_action",
1737        FkAction::Cascade => "cascade",
1738        FkAction::SetNull => "set_null",
1739        FkAction::SetDefault => "set_default",
1740        FkAction::Restrict => "restrict",
1741    }
1742}
1743
1744fn format_qail_value_token(value: &str, extra_special: &[char]) -> String {
1745    let needs_quotes = value.is_empty()
1746        || value.chars().any(|ch| {
1747            ch.is_whitespace() || matches!(ch, ',' | '\'' | '"') || extra_special.contains(&ch)
1748        });
1749
1750    if needs_quotes {
1751        format!("\"{}\"", value.replace('"', "\"\""))
1752    } else {
1753        value.to_string()
1754    }
1755}
1756
1757fn format_check_in_value(value: &str) -> String {
1758    format_qail_value_token(value, &['[', ']'])
1759}
1760
1761fn format_sql_text_literal(value: &str) -> String {
1762    format!("'{}'", value.replace('\'', "''"))
1763}
1764
1765fn format_sql_text_literal_with_cast(value: &str, cast: &Option<String>) -> String {
1766    let literal = format_sql_text_literal(value);
1767    match cast {
1768        Some(cast) => format!("{literal}::{cast}"),
1769        None => literal,
1770    }
1771}
1772
1773/// Serialize CheckExpr to QAIL check syntax
1774fn check_expr_str(expr: &CheckExpr) -> String {
1775    match expr {
1776        CheckExpr::GreaterThan { column, value } => format!("{} > {}", column, value),
1777        CheckExpr::GreaterOrEqual { column, value } => format!("{} >= {}", column, value),
1778        CheckExpr::LessThan { column, value } => format!("{} < {}", column, value),
1779        CheckExpr::LessOrEqual { column, value } => format!("{} <= {}", column, value),
1780        CheckExpr::Between { column, low, high } => format!("{} between {} {}", column, low, high),
1781        CheckExpr::In { column, values } => format!(
1782            "{} in [{}]",
1783            column,
1784            values
1785                .iter()
1786                .map(|value| format_check_in_value(value))
1787                .collect::<Vec<_>>()
1788                .join(", ")
1789        ),
1790        CheckExpr::InIntegers { column, values } => format!(
1791            "{} = ANY (ARRAY[{}])",
1792            column,
1793            values
1794                .iter()
1795                .map(i64::to_string)
1796                .collect::<Vec<_>>()
1797                .join(", ")
1798        ),
1799        CheckExpr::CompareColumns {
1800            left_column,
1801            op,
1802            right_column,
1803        } => format!("{} {} {}", left_column, op.as_sql_str(), right_column),
1804        CheckExpr::TextCompare { column, op, value } => {
1805            format!(
1806                "{} {} {}",
1807                column,
1808                op.as_sql_str(),
1809                format_sql_text_literal(value)
1810            )
1811        }
1812        CheckExpr::CompareColumnToCoalesce {
1813            left_column,
1814            op,
1815            coalesce_column,
1816            fallback,
1817            fallback_cast,
1818        } => format!(
1819            "{} {} COALESCE({}, {})",
1820            left_column,
1821            op.as_sql_str(),
1822            coalesce_column,
1823            format_sql_text_literal_with_cast(fallback, fallback_cast)
1824        ),
1825        CheckExpr::LowerTrimEquals { column } => format!("{column} = lower(btrim({column}))"),
1826        CheckExpr::Regex { column, pattern } => {
1827            format!("{} ~ {}", column, format_sql_text_literal(pattern))
1828        }
1829        CheckExpr::MaxLength { column, max } => format!("length({}) <= {}", column, max),
1830        CheckExpr::MinLength { column, min } => format!("length({}) >= {}", column, min),
1831        CheckExpr::NotNull { column } => format!("{} not_null", column),
1832        CheckExpr::And(l, r) => format!("{} and {}", check_expr_str(l), check_expr_str(r)),
1833        CheckExpr::Or(l, r) => format!("{} or {}", check_expr_str(l), check_expr_str(r)),
1834        CheckExpr::Not(e) => format!("not {}", check_expr_str(e)),
1835        CheckExpr::Sql(sql) => sql.clone(),
1836    }
1837}
1838
1839fn format_enum_value(value: &str) -> String {
1840    format_qail_value_token(value, &['{', '}'])
1841}
1842
1843fn dollar_quote_qail_body(body: &str) -> String {
1844    let delimiter = if !body.contains("$$") {
1845        "$$".to_string()
1846    } else {
1847        let mut idx = 0usize;
1848        loop {
1849            let candidate = if idx == 0 {
1850                "$qail$".to_string()
1851            } else {
1852                format!("$qail{idx}$")
1853            };
1854            if !body.contains(&candidate) {
1855                break candidate;
1856            }
1857            idx = idx.saturating_add(1);
1858        }
1859    };
1860
1861    format!("{delimiter}\n{body}\n{delimiter}")
1862}
1863
1864/// Serialize a `Schema` back to a QAIL-format string.
1865pub fn to_qail_string(schema: &Schema) -> String {
1866    let mut output = String::new();
1867    output.push_str("# QAIL Schema\n\n");
1868
1869    // Extensions first (must be created before any DDL)
1870    for ext in &schema.extensions {
1871        let mut line = format!("extension {}", quote_qail_string(&ext.name));
1872        if let Some(ref s) = ext.schema {
1873            line.push_str(&format!(" schema {}", quote_qail_string(s)));
1874        }
1875        if let Some(ref v) = ext.version {
1876            line.push_str(&format!(" version {}", quote_qail_string(v)));
1877        }
1878        output.push_str(&line);
1879        output.push('\n');
1880    }
1881    if !schema.extensions.is_empty() {
1882        output.push('\n');
1883    }
1884
1885    // Enums (CREATE TYPE ... AS ENUM, must precede tables)
1886    for enum_type in &schema.enums {
1887        let values = enum_type
1888            .values
1889            .iter()
1890            .map(|v| format_enum_value(v))
1891            .collect::<Vec<_>>()
1892            .join(", ");
1893        output.push_str(&format!("enum {} {{ {} }}\n", enum_type.name, values));
1894    }
1895    if !schema.enums.is_empty() {
1896        output.push('\n');
1897    }
1898
1899    // Sequences (before tables, since columns may reference them)
1900    for seq in &schema.sequences {
1901        if seq.start.is_some()
1902            || seq.increment.is_some()
1903            || seq.min_value.is_some()
1904            || seq.max_value.is_some()
1905            || seq.cache.is_some()
1906            || seq.cycle
1907            || seq.owned_by.is_some()
1908        {
1909            let mut opts = Vec::new();
1910            if let Some(v) = seq.start {
1911                opts.push(format!("start {}", v));
1912            }
1913            if let Some(v) = seq.increment {
1914                opts.push(format!("increment {}", v));
1915            }
1916            if let Some(v) = seq.min_value {
1917                opts.push(format!("minvalue {}", v));
1918            }
1919            if let Some(v) = seq.max_value {
1920                opts.push(format!("maxvalue {}", v));
1921            }
1922            if let Some(v) = seq.cache {
1923                opts.push(format!("cache {}", v));
1924            }
1925            if seq.cycle {
1926                opts.push("cycle".to_string());
1927            }
1928            if let Some(ref o) = seq.owned_by {
1929                opts.push(format!("owned_by {}", o));
1930            }
1931            output.push_str(&format!("sequence {} {{ {} }}\n", seq.name, opts.join(" ")));
1932        } else {
1933            output.push_str(&format!("sequence {}\n", seq.name));
1934        }
1935    }
1936    if !schema.sequences.is_empty() {
1937        output.push('\n');
1938    }
1939
1940    let mut table_names: Vec<&String> = schema.tables.keys().collect();
1941    table_names.sort();
1942    for table_name in table_names {
1943        let table = &schema.tables[table_name];
1944        output.push_str(&format!("table {} {{\n", table.name));
1945        for col in &table.columns {
1946            let mut constraints: Vec<String> = Vec::new();
1947            if col.primary_key {
1948                constraints.push("primary_key".to_string());
1949            }
1950            if !col.nullable && !col.primary_key {
1951                constraints.push("not_null".to_string());
1952            }
1953            if col.unique {
1954                constraints.push("unique".to_string());
1955            }
1956            if let Some(def) = &col.default {
1957                constraints.push(format!("default {}", def));
1958            }
1959            if let Some(generated) = &col.generated {
1960                match generated {
1961                    Generated::AlwaysStored(expr) => {
1962                        constraints.push(format!("generated_stored({})", expr));
1963                    }
1964                    Generated::AlwaysIdentity => {
1965                        constraints.push("generated_identity".to_string());
1966                    }
1967                    Generated::ByDefaultIdentity => {
1968                        constraints.push("generated_by_default_identity".to_string());
1969                    }
1970                }
1971            }
1972            if let Some(ref fk) = col.foreign_key {
1973                let mut fk_str = format!("references {}({})", fk.table, fk.column);
1974                if fk.on_delete != FkAction::NoAction {
1975                    fk_str.push_str(&format!(" on_delete {}", fk_action_str(&fk.on_delete)));
1976                }
1977                if fk.on_update != FkAction::NoAction {
1978                    fk_str.push_str(&format!(" on_update {}", fk_action_str(&fk.on_update)));
1979                }
1980                match &fk.deferrable {
1981                    Deferrable::Deferrable => fk_str.push_str(" deferrable"),
1982                    Deferrable::InitiallyDeferred => fk_str.push_str(" initially_deferred"),
1983                    Deferrable::InitiallyImmediate => fk_str.push_str(" initially_immediate"),
1984                    Deferrable::NotDeferrable => {} // default, omit
1985                }
1986                constraints.push(fk_str);
1987            }
1988            if let Some(ref check) = col.check {
1989                constraints.push(format!("check({})", check_expr_str(&check.expr)));
1990                if let Some(name) = &check.name {
1991                    constraints.push(format!("check_name {}", name));
1992                }
1993            }
1994
1995            let constraint_str = if constraints.is_empty() {
1996                String::new()
1997            } else {
1998                format!(" {}", constraints.join(" "))
1999            };
2000
2001            output.push_str(&format!(
2002                "  {} {}{}\n",
2003                col.name,
2004                col.data_type.to_pg_type(),
2005                constraint_str
2006            ));
2007        }
2008        // Multi-column foreign keys
2009        for fk in &table.multi_column_fks {
2010            let mut fk_line = format!(
2011                "  foreign_key ({}) references {}({})\n",
2012                fk.columns.join(", "),
2013                fk.ref_table,
2014                fk.ref_columns.join(", ")
2015            );
2016            if fk.name.is_some()
2017                || fk.on_delete != FkAction::NoAction
2018                || fk.on_update != FkAction::NoAction
2019                || fk.deferrable != Deferrable::NotDeferrable
2020            {
2021                fk_line.pop();
2022                if let Some(name) = &fk.name {
2023                    fk_line.push_str(&format!(" constraint {}", name));
2024                }
2025                if fk.on_delete != FkAction::NoAction {
2026                    fk_line.push_str(&format!(" on_delete {}", fk_action_str(&fk.on_delete)));
2027                }
2028                if fk.on_update != FkAction::NoAction {
2029                    fk_line.push_str(&format!(" on_update {}", fk_action_str(&fk.on_update)));
2030                }
2031                match &fk.deferrable {
2032                    Deferrable::Deferrable => fk_line.push_str(" deferrable"),
2033                    Deferrable::InitiallyDeferred => fk_line.push_str(" initially_deferred"),
2034                    Deferrable::InitiallyImmediate => fk_line.push_str(" initially_immediate"),
2035                    Deferrable::NotDeferrable => {}
2036                }
2037                fk_line.push('\n');
2038            }
2039            output.push_str(&fk_line);
2040        }
2041        // RLS directives
2042        if table.enable_rls {
2043            output.push_str("  enable_rls\n");
2044        }
2045        if table.force_rls {
2046            output.push_str("  force_rls\n");
2047        }
2048        output.push_str("}\n\n");
2049    }
2050
2051    for idx in &schema.indexes {
2052        let unique = if idx.unique { "unique " } else { "" };
2053        let concurrently = if idx.concurrently {
2054            "concurrently "
2055        } else {
2056            ""
2057        };
2058        let cols = if !idx.expressions.is_empty() {
2059            idx.expressions.join(", ")
2060        } else {
2061            idx.columns.join(", ")
2062        };
2063        let mut line = format!(
2064            "{}index {}{} on {}",
2065            unique, concurrently, idx.name, idx.table
2066        );
2067        if idx.method != IndexMethod::BTree {
2068            line.push_str(" using ");
2069            line.push_str(index_method_str(&idx.method));
2070        }
2071        line.push_str(" (");
2072        line.push_str(&cols);
2073        line.push(')');
2074        if !idx.include.is_empty() {
2075            line.push_str(" include (");
2076            line.push_str(&idx.include.join(", "));
2077            line.push(')');
2078        }
2079        if let Some(where_clause) = &idx.where_clause {
2080            line.push_str(" where ");
2081            line.push_str(&check_expr_str(where_clause));
2082        }
2083        output.push_str(&line);
2084        output.push('\n');
2085    }
2086
2087    for hint in &schema.migrations {
2088        match hint {
2089            MigrationHint::Rename { from, to } => {
2090                output.push_str(&format!("rename {} -> {}\n", from, to));
2091            }
2092            MigrationHint::Transform { expression, target } => {
2093                output.push_str(&format!("transform {} -> {}\n", expression, target));
2094            }
2095            MigrationHint::Drop { target, confirmed } => {
2096                let confirm = if *confirmed { " confirm" } else { "" };
2097                output.push_str(&format!("drop {}{}\n", target, confirm));
2098            }
2099        }
2100    }
2101
2102    // Views
2103    for view in &schema.views {
2104        let prefix = if view.materialized {
2105            "materialized view"
2106        } else {
2107            "view"
2108        };
2109        let body = dollar_quote_qail_body(&view.query);
2110        output.push_str(&format!("{} {} {}\n\n", prefix, view.name, body));
2111    }
2112
2113    // Functions
2114    for func in &schema.functions {
2115        let args = func.args.join(", ");
2116        let volatility = func
2117            .volatility
2118            .as_deref()
2119            .filter(|v| !v.trim().is_empty())
2120            .map(|v| format!(" {}", v))
2121            .unwrap_or_default();
2122        let body = dollar_quote_qail_body(&func.body);
2123        output.push_str(&format!(
2124            "function {}({}) returns {} language {}{} {}\n\n",
2125            func.name, args, func.returns, func.language, volatility, body
2126        ));
2127    }
2128
2129    // Triggers
2130    for trigger in &schema.triggers {
2131        let mut events = Vec::new();
2132        for evt in &trigger.events {
2133            if evt.eq_ignore_ascii_case("UPDATE") && !trigger.update_columns.is_empty() {
2134                events.push(format!("UPDATE OF {}", trigger.update_columns.join(", ")));
2135            } else {
2136                events.push(evt.clone());
2137            }
2138        }
2139        output.push_str(&format!(
2140            "trigger {} on {} {} {} execute {}\n",
2141            trigger.name,
2142            trigger.table,
2143            trigger.timing.to_lowercase(),
2144            events.join(" or ").to_lowercase(),
2145            trigger.execute_function
2146        ));
2147    }
2148    if !schema.triggers.is_empty() {
2149        output.push('\n');
2150    }
2151
2152    // Policies
2153    for policy in &schema.policies {
2154        let cmd = match policy.target {
2155            PolicyTarget::All => "all",
2156            PolicyTarget::Select => "select",
2157            PolicyTarget::Insert => "insert",
2158            PolicyTarget::Update => "update",
2159            PolicyTarget::Delete => "delete",
2160        };
2161        let perm = match policy.permissiveness {
2162            PolicyPermissiveness::Permissive => "",
2163            PolicyPermissiveness::Restrictive => " restrictive",
2164        };
2165        let role_str = match &policy.role {
2166            Some(r) => format!(" to {}", r),
2167            None => String::new(),
2168        };
2169        output.push_str(&format!(
2170            "policy {} on {} for {}{}{}",
2171            policy.name, policy.table, cmd, role_str, perm
2172        ));
2173        if let Some(ref using) = policy.using {
2174            output.push_str(&format!("\n  using $$ {} $$", using));
2175        }
2176        if let Some(ref wc) = policy.with_check {
2177            output.push_str(&format!("\n  with_check $$ {} $$", wc));
2178        }
2179        output.push_str("\n\n");
2180    }
2181
2182    // Grants
2183    for grant in &schema.grants {
2184        let privs: Vec<String> = grant
2185            .privileges
2186            .iter()
2187            .map(|p| p.to_string().to_lowercase())
2188            .collect();
2189        match grant.action {
2190            GrantAction::Grant => {
2191                output.push_str(&format!(
2192                    "grant {} on {} to {}\n",
2193                    privs.join(", "),
2194                    grant.on_object,
2195                    grant.to_role
2196                ));
2197            }
2198            GrantAction::Revoke => {
2199                output.push_str(&format!(
2200                    "revoke {} on {} from {}\n",
2201                    privs.join(", "),
2202                    grant.on_object,
2203                    grant.to_role
2204                ));
2205            }
2206        }
2207    }
2208    if !schema.grants.is_empty() {
2209        output.push('\n');
2210    }
2211
2212    // Comments last (tables must exist first)
2213    for comment in &schema.comments {
2214        let text = quote_qail_string(&comment.text);
2215        match &comment.target {
2216            CommentTarget::Table(t) => {
2217                output.push_str(&format!("comment on {} {}\n", t, text));
2218            }
2219            CommentTarget::Column { table, column } => {
2220                output.push_str(&format!("comment on {}.{} {}\n", table, column, text));
2221            }
2222            CommentTarget::Raw(target) => {
2223                output.push_str(&format!("comment on {} {}\n", target, text));
2224            }
2225        }
2226    }
2227
2228    output
2229}
2230
2231fn quote_qail_string(value: &str) -> String {
2232    format!("\"{}\"", value.replace('"', "\"\""))
2233}
2234
2235/// Convert a Schema to a list of Qail commands (CREATE TABLE, CREATE INDEX).
2236/// Used by shadow migration to apply the base schema before applying diffs.
2237pub fn schema_to_commands(schema: &Schema) -> Vec<crate::ast::Qail> {
2238    use crate::ast::{Action, ColumnGeneration, Constraint, Expr, IndexDef, Qail};
2239
2240    let mut cmds = Vec::new();
2241
2242    // Topologically sort tables by FK dependencies:
2243    // referenced targets must be created before dependent tables.
2244    let mut indegree: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
2245    let mut reverse_adj: std::collections::HashMap<String, Vec<String>> =
2246        std::collections::HashMap::new();
2247
2248    for name in schema.tables.keys() {
2249        indegree.insert(name.clone(), 0);
2250    }
2251
2252    for table in schema.tables.values() {
2253        let mut deps = std::collections::HashSet::new();
2254        for col in &table.columns {
2255            if let Some(fk) = &col.foreign_key
2256                && fk.table != table.name
2257                && schema.tables.contains_key(&fk.table)
2258            {
2259                deps.insert(fk.table.clone());
2260            }
2261        }
2262        for fk in &table.multi_column_fks {
2263            if fk.ref_table != table.name && schema.tables.contains_key(&fk.ref_table) {
2264                deps.insert(fk.ref_table.clone());
2265            }
2266        }
2267
2268        indegree.insert(table.name.clone(), deps.len());
2269        for dep in deps {
2270            reverse_adj.entry(dep).or_default().push(table.name.clone());
2271        }
2272    }
2273
2274    let mut ready = std::collections::BTreeSet::new();
2275    for (name, deg) in &indegree {
2276        if *deg == 0 {
2277            ready.insert(name.clone());
2278        }
2279    }
2280
2281    let mut ordered_names: Vec<String> = Vec::with_capacity(schema.tables.len());
2282    while let Some(next) = ready.pop_first() {
2283        ordered_names.push(next.clone());
2284        if let Some(dependents) = reverse_adj.get(&next) {
2285            for dep_name in dependents {
2286                if let Some(d) = indegree.get_mut(dep_name)
2287                    && *d > 0
2288                {
2289                    *d -= 1;
2290                    if *d == 0 {
2291                        ready.insert(dep_name.clone());
2292                    }
2293                }
2294            }
2295        }
2296    }
2297
2298    // If there is an FK cycle, append remaining names in lexical order
2299    // so output is deterministic (runtime may still reject unresolved cycle).
2300    if ordered_names.len() < schema.tables.len() {
2301        let mut leftovers: Vec<String> = schema
2302            .tables
2303            .keys()
2304            .filter(|name| !ordered_names.contains(*name))
2305            .cloned()
2306            .collect();
2307        leftovers.sort();
2308        ordered_names.extend(leftovers);
2309    }
2310
2311    for table_name in ordered_names {
2312        let table = &schema.tables[&table_name];
2313        // Build columns using Expr::Def exactly like diff.rs does
2314        let columns: Vec<Expr> = table
2315            .columns
2316            .iter()
2317            .map(|col| {
2318                let mut constraints = Vec::new();
2319
2320                if col.primary_key {
2321                    constraints.push(Constraint::PrimaryKey);
2322                }
2323                if col.nullable {
2324                    constraints.push(Constraint::Nullable);
2325                }
2326                if col.unique {
2327                    constraints.push(Constraint::Unique);
2328                }
2329                if let Some(def) = &col.default {
2330                    constraints.push(Constraint::Default(def.clone()));
2331                }
2332                if let Some(ref fk) = col.foreign_key {
2333                    constraints.push(Constraint::References(foreign_key_to_sql(fk)));
2334                }
2335                if let Some(check) = &col.check {
2336                    let check_sql = check_expr_to_sql(&check.expr);
2337                    if let Some(name) = &check.name {
2338                        constraints.push(Constraint::Check(vec![format!(
2339                            "CONSTRAINT {} CHECK ({})",
2340                            name, check_sql
2341                        )]));
2342                    } else {
2343                        constraints.push(Constraint::Check(vec![check_sql]));
2344                    }
2345                }
2346                if let Some(generated) = &col.generated {
2347                    let gen_constraint = match generated {
2348                        Generated::AlwaysStored(expr) => {
2349                            Constraint::Generated(ColumnGeneration::Stored(expr.clone()))
2350                        }
2351                        Generated::AlwaysIdentity => {
2352                            Constraint::Generated(ColumnGeneration::Stored("identity".to_string()))
2353                        }
2354                        Generated::ByDefaultIdentity => Constraint::Generated(
2355                            ColumnGeneration::Stored("identity_by_default".to_string()),
2356                        ),
2357                    };
2358                    constraints.push(gen_constraint);
2359                }
2360
2361                Expr::Def {
2362                    name: col.name.clone(),
2363                    data_type: col.data_type.to_pg_type(),
2364                    constraints,
2365                }
2366            })
2367            .collect();
2368
2369        cmds.push(Qail {
2370            action: Action::Make,
2371            table: table.name.clone(),
2372            columns,
2373            ..Default::default()
2374        });
2375
2376        if table.enable_rls {
2377            cmds.push(Qail {
2378                action: Action::AlterEnableRls,
2379                table: table.name.clone(),
2380                ..Default::default()
2381            });
2382        }
2383        if table.force_rls {
2384            cmds.push(Qail {
2385                action: Action::AlterForceRls,
2386                table: table.name.clone(),
2387                ..Default::default()
2388            });
2389        }
2390    }
2391
2392    // Add indexes using IndexDef like diff.rs
2393    for idx in &schema.indexes {
2394        cmds.push(Qail {
2395            action: Action::Index,
2396            table: String::new(),
2397            index_def: Some(IndexDef {
2398                name: idx.name.clone(),
2399                table: idx.table.clone(),
2400                columns: if !idx.expressions.is_empty() {
2401                    idx.expressions.clone()
2402                } else {
2403                    idx.columns.clone()
2404                },
2405                unique: idx.unique,
2406                index_type: Some(index_method_str(&idx.method).to_string()),
2407                include: idx.include.clone(),
2408                concurrently: idx.concurrently,
2409                where_clause: idx.where_clause.as_ref().map(check_expr_to_sql),
2410            }),
2411            ..Default::default()
2412        });
2413    }
2414
2415    let mut fk_table_names: Vec<&String> = schema
2416        .tables
2417        .iter()
2418        .filter(|(_, table)| !table.multi_column_fks.is_empty())
2419        .map(|(name, _)| name)
2420        .collect();
2421    fk_table_names.sort();
2422    for table_name in fk_table_names {
2423        let table = &schema.tables[table_name];
2424        for fk in &table.multi_column_fks {
2425            cmds.push(multi_column_fk_to_alter_command(&table.name, fk));
2426        }
2427    }
2428
2429    cmds
2430}
2431
2432pub(super) fn multi_column_fk_to_table_constraint(
2433    fk: &MultiColumnForeignKey,
2434) -> crate::ast::TableConstraint {
2435    crate::ast::TableConstraint::ForeignKey {
2436        name: fk.name.clone(),
2437        columns: fk.columns.clone(),
2438        ref_table: fk.ref_table.clone(),
2439        ref_columns: fk.ref_columns.clone(),
2440        on_delete: (fk.on_delete != FkAction::NoAction)
2441            .then(|| fk_action_to_sql(&fk.on_delete).to_string()),
2442        on_update: (fk.on_update != FkAction::NoAction)
2443            .then(|| fk_action_to_sql(&fk.on_update).to_string()),
2444        deferrable: deferrable_to_sql(&fk.deferrable).map(str::to_string),
2445    }
2446}
2447
2448pub(super) fn multi_column_fk_to_alter_command(
2449    table_name: &str,
2450    fk: &MultiColumnForeignKey,
2451) -> crate::ast::Qail {
2452    crate::ast::Qail {
2453        action: crate::ast::Action::Alter,
2454        table: table_name.to_string(),
2455        table_constraints: vec![multi_column_fk_to_table_constraint(fk)],
2456        ..Default::default()
2457    }
2458}
2459
2460fn fk_action_to_sql(action: &FkAction) -> &'static str {
2461    match action {
2462        FkAction::NoAction => "NO ACTION",
2463        FkAction::Cascade => "CASCADE",
2464        FkAction::SetNull => "SET NULL",
2465        FkAction::SetDefault => "SET DEFAULT",
2466        FkAction::Restrict => "RESTRICT",
2467    }
2468}
2469
2470fn deferrable_to_sql(deferrable: &Deferrable) -> Option<&'static str> {
2471    match deferrable {
2472        Deferrable::NotDeferrable => None,
2473        Deferrable::Deferrable => Some("DEFERRABLE"),
2474        Deferrable::InitiallyDeferred => Some("DEFERRABLE INITIALLY DEFERRED"),
2475        Deferrable::InitiallyImmediate => Some("DEFERRABLE INITIALLY IMMEDIATE"),
2476    }
2477}
2478
2479pub(crate) fn foreign_key_to_sql(fk: &ForeignKey) -> String {
2480    let mut target = format!("{}({})", fk.table, fk.column);
2481    if fk.on_delete != FkAction::NoAction {
2482        target.push_str(" ON DELETE ");
2483        target.push_str(fk_action_to_sql(&fk.on_delete));
2484    }
2485    if fk.on_update != FkAction::NoAction {
2486        target.push_str(" ON UPDATE ");
2487        target.push_str(fk_action_to_sql(&fk.on_update));
2488    }
2489    if let Some(def) = deferrable_to_sql(&fk.deferrable) {
2490        target.push(' ');
2491        target.push_str(def);
2492    }
2493    target
2494}
2495
2496pub(crate) fn check_expr_to_sql(expr: &CheckExpr) -> String {
2497    match expr {
2498        CheckExpr::GreaterThan { column, value } => format!("{column} > {value}"),
2499        CheckExpr::GreaterOrEqual { column, value } => format!("{column} >= {value}"),
2500        CheckExpr::LessThan { column, value } => format!("{column} < {value}"),
2501        CheckExpr::LessOrEqual { column, value } => format!("{column} <= {value}"),
2502        CheckExpr::Between { column, low, high } => format!("{column} BETWEEN {low} AND {high}"),
2503        CheckExpr::In { column, values } => {
2504            if values.len() == 1 && looks_like_raw_check_expr(&values[0]) {
2505                return values[0].clone();
2506            }
2507            let quoted = values
2508                .iter()
2509                .map(|v| format!("'{}'", v.replace('\'', "''")))
2510                .collect::<Vec<_>>()
2511                .join(", ");
2512            format!("{column} IN ({quoted})")
2513        }
2514        CheckExpr::InIntegers { column, values } => format!(
2515            "{column} IN ({})",
2516            values
2517                .iter()
2518                .map(i64::to_string)
2519                .collect::<Vec<_>>()
2520                .join(", ")
2521        ),
2522        CheckExpr::CompareColumns {
2523            left_column,
2524            op,
2525            right_column,
2526        } => format!("{left_column} {} {right_column}", op.as_sql_str()),
2527        CheckExpr::TextCompare { column, op, value } => {
2528            format!(
2529                "{column} {} {}",
2530                op.as_sql_str(),
2531                format_sql_text_literal(value)
2532            )
2533        }
2534        CheckExpr::CompareColumnToCoalesce {
2535            left_column,
2536            op,
2537            coalesce_column,
2538            fallback,
2539            fallback_cast,
2540        } => format!(
2541            "{left_column} {} COALESCE({coalesce_column}, {})",
2542            op.as_sql_str(),
2543            format_sql_text_literal_with_cast(fallback, fallback_cast)
2544        ),
2545        CheckExpr::LowerTrimEquals { column } => format!("{column} = lower(btrim({column}))"),
2546        CheckExpr::Regex { column, pattern } => {
2547            format!("{column} ~ {}", format_sql_text_literal(pattern))
2548        }
2549        CheckExpr::MaxLength { column, max } => format!("char_length({column}) <= {max}"),
2550        CheckExpr::MinLength { column, min } => format!("char_length({column}) >= {min}"),
2551        CheckExpr::NotNull { column } => format!("{column} IS NOT NULL"),
2552        CheckExpr::And(left, right) => {
2553            format!(
2554                "({}) AND ({})",
2555                check_expr_to_sql(left),
2556                check_expr_to_sql(right)
2557            )
2558        }
2559        CheckExpr::Or(left, right) => {
2560            format!(
2561                "({}) OR ({})",
2562                check_expr_to_sql(left),
2563                check_expr_to_sql(right)
2564            )
2565        }
2566        CheckExpr::Not(inner) => format!("NOT ({})", check_expr_to_sql(inner)),
2567        CheckExpr::Sql(sql) => sql.clone(),
2568    }
2569}
2570
2571fn looks_like_raw_check_expr(s: &str) -> bool {
2572    s.chars()
2573        .any(|c| c.is_whitespace() || matches!(c, '<' | '>' | '=' | '!' | '(' | ')' | ':'))
2574}
2575
2576#[cfg(test)]
2577mod tests {
2578    use super::*;
2579
2580    #[test]
2581    fn test_schema_builder() {
2582        let mut schema = Schema::new();
2583
2584        let users = Table::new("users")
2585            .column(Column::new("id", ColumnType::Serial).primary_key())
2586            .column(Column::new("name", ColumnType::Text).not_null())
2587            .column(Column::new("email", ColumnType::Text).unique());
2588
2589        schema.add_table(users);
2590        schema.add_index(Index::new("idx_users_email", "users", vec!["email".into()]).unique());
2591
2592        let output = to_qail_string(&schema);
2593        assert!(output.contains("table users"));
2594        assert!(output.contains("id SERIAL primary_key"));
2595        assert!(output.contains("unique index idx_users_email"));
2596    }
2597
2598    #[test]
2599    fn test_to_qail_string_preserves_vector_index_methods() {
2600        let mut schema = Schema::new();
2601        schema.add_index(
2602            Index::new(
2603                "idx_docs_embedding_hnsw",
2604                "documents",
2605                vec!["embedding vector_l2_ops".into()],
2606            )
2607            .using(IndexMethod::Hnsw),
2608        );
2609        schema.add_index(
2610            Index::new(
2611                "idx_docs_embedding_ivfflat",
2612                "documents",
2613                vec!["embedding vector_cosine_ops".into()],
2614            )
2615            .using(IndexMethod::IvfFlat),
2616        );
2617
2618        let output = to_qail_string(&schema);
2619
2620        assert!(output.contains(
2621            "index idx_docs_embedding_hnsw on documents using hnsw (embedding vector_l2_ops)"
2622        ));
2623        assert!(output.contains(
2624            "index idx_docs_embedding_ivfflat on documents using ivfflat (embedding vector_cosine_ops)"
2625        ));
2626    }
2627
2628    #[test]
2629    fn test_to_qail_string_preserves_covering_concurrent_index_options() {
2630        let mut schema = Schema::new();
2631        schema.add_index(
2632            Index::new("idx_users_email_cover", "users", vec!["email".into()])
2633                .unique()
2634                .include(vec!["name".into(), "created_at".into()])
2635                .concurrently()
2636                .partial(CheckExpr::Sql("deleted_at IS NULL".to_string())),
2637        );
2638
2639        let output = to_qail_string(&schema);
2640
2641        assert!(output.contains(
2642            "unique index concurrently idx_users_email_cover on users (email) include (name, created_at) where deleted_at IS NULL"
2643        ));
2644    }
2645
2646    #[test]
2647    fn test_migration_hints() {
2648        let mut schema = Schema::new();
2649        schema.add_hint(MigrationHint::Rename {
2650            from: "users.username".into(),
2651            to: "users.name".into(),
2652        });
2653
2654        let output = to_qail_string(&schema);
2655        assert!(output.contains("rename users.username -> users.name"));
2656    }
2657
2658    #[test]
2659    fn test_to_qail_string_includes_function_volatility() {
2660        let mut schema = Schema::new();
2661        let func = SchemaFunctionDef::new(
2662            "is_super_admin",
2663            "boolean",
2664            "BEGIN RETURN true; END;".to_string(),
2665        )
2666        .language("plpgsql")
2667        .volatility("stable");
2668        schema.add_function(func);
2669
2670        let output = to_qail_string(&schema);
2671        assert!(
2672            output.contains("function is_super_admin() returns boolean language plpgsql stable $$")
2673        );
2674    }
2675
2676    #[test]
2677    fn test_invalid_primary_key_type_strict() {
2678        let err = Column::new("data", ColumnType::Jsonb)
2679            .try_primary_key()
2680            .expect_err("JSONB should be rejected by strict PK policy");
2681        assert!(err.contains("cannot be a primary key"));
2682    }
2683
2684    #[test]
2685    fn test_invalid_primary_key_type_fail_soft() {
2686        let col = Column::new("data", ColumnType::Jsonb).primary_key();
2687        assert!(col.primary_key);
2688        assert!(!col.nullable);
2689    }
2690
2691    #[test]
2692    fn test_invalid_unique_type_strict() {
2693        let err = Column::new("data", ColumnType::Jsonb)
2694            .try_unique()
2695            .expect_err("JSONB should be rejected by strict UNIQUE policy");
2696        assert!(err.contains("cannot have UNIQUE"));
2697    }
2698
2699    #[test]
2700    fn test_invalid_unique_type_fail_soft() {
2701        let col = Column::new("data", ColumnType::Jsonb).unique();
2702        assert!(col.unique);
2703    }
2704
2705    #[test]
2706    fn test_validate_rejects_invalid_primary_key_type() {
2707        let mut schema = Schema::new();
2708        schema.add_table(
2709            Table::new("events").column(Column::new("data", ColumnType::Jsonb).primary_key()),
2710        );
2711
2712        let errors = schema
2713            .validate()
2714            .expect_err("invalid primary-key type should fail validation");
2715        assert!(
2716            errors.iter().any(|err| {
2717                err.contains("events.data")
2718                    && err.contains("JSONB")
2719                    && err.contains("cannot be a primary key")
2720            }),
2721            "{errors:?}"
2722        );
2723    }
2724
2725    #[test]
2726    fn test_validate_rejects_invalid_unique_type() {
2727        let mut schema = Schema::new();
2728        schema.add_table(
2729            Table::new("events").column(Column::new("data", ColumnType::Jsonb).unique()),
2730        );
2731
2732        let errors = schema
2733            .validate()
2734            .expect_err("invalid unique type should fail validation");
2735        assert!(
2736            errors.iter().any(|err| {
2737                err.contains("events.data")
2738                    && err.contains("JSONB")
2739                    && err.contains("cannot have UNIQUE")
2740            }),
2741            "{errors:?}"
2742        );
2743    }
2744
2745    #[test]
2746    fn test_foreign_key_valid() {
2747        let mut schema = Schema::new();
2748
2749        schema.add_table(
2750            Table::new("users").column(Column::new("id", ColumnType::Uuid).primary_key()),
2751        );
2752
2753        schema.add_table(
2754            Table::new("posts")
2755                .column(Column::new("id", ColumnType::Uuid).primary_key())
2756                .column(
2757                    Column::new("user_id", ColumnType::Uuid)
2758                        .references("users", "id")
2759                        .on_delete(FkAction::Cascade),
2760                ),
2761        );
2762
2763        // Should pass validation
2764        assert!(schema.validate().is_ok());
2765    }
2766
2767    #[test]
2768    fn test_foreign_key_invalid_table() {
2769        let mut schema = Schema::new();
2770
2771        schema.add_table(
2772            Table::new("posts")
2773                .column(Column::new("id", ColumnType::Uuid).primary_key())
2774                .column(Column::new("user_id", ColumnType::Uuid).references("nonexistent", "id")),
2775        );
2776
2777        // Should fail validation
2778        let result = schema.validate();
2779        assert!(result.is_err());
2780        assert!(result.unwrap_err()[0].contains("non-existent table"));
2781    }
2782
2783    #[test]
2784    fn test_foreign_key_invalid_column() {
2785        let mut schema = Schema::new();
2786
2787        schema.add_table(
2788            Table::new("users").column(Column::new("id", ColumnType::Uuid).primary_key()),
2789        );
2790
2791        schema.add_table(
2792            Table::new("posts")
2793                .column(Column::new("id", ColumnType::Uuid).primary_key())
2794                .column(
2795                    Column::new("user_id", ColumnType::Uuid).references("users", "wrong_column"),
2796                ),
2797        );
2798
2799        // Should fail validation
2800        let result = schema.validate();
2801        assert!(result.is_err());
2802        assert!(result.unwrap_err()[0].contains("non-existent column"));
2803    }
2804
2805    #[test]
2806    fn test_foreign_key_requires_unique_target() {
2807        let mut schema = Schema::new();
2808        schema.add_table(Table::new("users").column(Column::new("email", ColumnType::Text)));
2809        schema.add_table(
2810            Table::new("posts")
2811                .column(Column::new("id", ColumnType::Uuid).primary_key())
2812                .column(Column::new("author_email", ColumnType::Text).references("users", "email")),
2813        );
2814
2815        let errors = schema
2816            .validate()
2817            .expect_err("FK targets must be unique or primary-key backed");
2818        assert!(
2819            errors.iter().any(|err| err.contains("posts.author_email")
2820                && err.contains("without a UNIQUE or PRIMARY KEY constraint")),
2821            "{errors:?}"
2822        );
2823    }
2824
2825    #[test]
2826    fn test_multi_column_foreign_key_invalid_table_and_columns() {
2827        let mut schema = Schema::new();
2828        schema.add_table(
2829            Table::new("trips")
2830                .column(Column::new("route_id", ColumnType::Text))
2831                .foreign_key(MultiColumnForeignKey::new(
2832                    vec!["route_id".to_string(), "schedule_id".to_string()],
2833                    "schedules",
2834                    vec!["route_id".to_string(), "schedule_id".to_string()],
2835                )),
2836        );
2837
2838        let errors = schema
2839            .validate()
2840            .expect_err("invalid composite FK should fail validation");
2841        assert!(
2842            errors
2843                .iter()
2844                .any(|err| err.contains("non-existent source column 'trips.schedule_id'")),
2845            "{errors:?}"
2846        );
2847        assert!(
2848            errors
2849                .iter()
2850                .any(|err| err.contains("non-existent table 'schedules'")),
2851            "{errors:?}"
2852        );
2853    }
2854
2855    #[test]
2856    fn test_multi_column_foreign_key_invalid_target_column_and_arity() {
2857        let mut schema = Schema::new();
2858        schema.add_table(Table::new("schedules").column(Column::new("route_id", ColumnType::Text)));
2859        schema.add_table(
2860            Table::new("trips")
2861                .column(Column::new("route_id", ColumnType::Text))
2862                .foreign_key(MultiColumnForeignKey::new(
2863                    vec!["route_id".to_string()],
2864                    "schedules",
2865                    vec!["route_id".to_string(), "schedule_id".to_string()],
2866                )),
2867        );
2868
2869        let errors = schema
2870            .validate()
2871            .expect_err("invalid composite FK should fail validation");
2872        assert!(
2873            errors.iter().any(|err| err.contains("column count 1")),
2874            "{errors:?}"
2875        );
2876        assert!(
2877            errors
2878                .iter()
2879                .any(|err| err.contains("non-existent column 'schedules.schedule_id'")),
2880            "{errors:?}"
2881        );
2882    }
2883
2884    #[test]
2885    fn test_multi_column_foreign_key_requires_unique_target() {
2886        let mut schema = Schema::new();
2887        schema.add_table(
2888            Table::new("schedules")
2889                .column(Column::new("route_id", ColumnType::Text))
2890                .column(Column::new("schedule_id", ColumnType::Text)),
2891        );
2892        schema.add_table(
2893            Table::new("trips")
2894                .column(Column::new("route_id", ColumnType::Text))
2895                .column(Column::new("schedule_id", ColumnType::Text))
2896                .foreign_key(MultiColumnForeignKey::new(
2897                    vec!["route_id".to_string(), "schedule_id".to_string()],
2898                    "schedules",
2899                    vec!["route_id".to_string(), "schedule_id".to_string()],
2900                )),
2901        );
2902
2903        let errors = schema
2904            .validate()
2905            .expect_err("composite FK targets must have a matching unique key");
2906        assert!(
2907            errors.iter().any(|err| {
2908                err.contains("Multi-column FK error")
2909                    && err.contains("schedules(route_id, schedule_id)")
2910                    && err.contains("matching UNIQUE or PRIMARY KEY")
2911            }),
2912            "{errors:?}"
2913        );
2914    }
2915
2916    #[test]
2917    fn test_multi_column_foreign_key_valid_with_unique_index() {
2918        let mut schema = Schema::new();
2919        schema.add_table(
2920            Table::new("schedules")
2921                .column(Column::new("route_id", ColumnType::Text))
2922                .column(Column::new("schedule_id", ColumnType::Text)),
2923        );
2924        schema.add_index(
2925            Index::new(
2926                "schedules_route_schedule_key",
2927                "schedules",
2928                vec!["route_id".to_string(), "schedule_id".to_string()],
2929            )
2930            .unique(),
2931        );
2932        schema.add_table(
2933            Table::new("trips")
2934                .column(Column::new("route_id", ColumnType::Text))
2935                .column(Column::new("schedule_id", ColumnType::Text))
2936                .foreign_key(MultiColumnForeignKey::new(
2937                    vec!["route_id".to_string(), "schedule_id".to_string()],
2938                    "schedules",
2939                    vec!["route_id".to_string(), "schedule_id".to_string()],
2940                )),
2941        );
2942
2943        assert!(schema.validate().is_ok());
2944    }
2945
2946    #[test]
2947    fn test_validate_rejects_duplicate_columns() {
2948        let mut schema = Schema::new();
2949        schema.add_table(
2950            Table::new("users")
2951                .column(Column::new("email", ColumnType::Text))
2952                .column(Column::new("email", ColumnType::Text)),
2953        );
2954
2955        let errors = schema
2956            .validate()
2957            .expect_err("duplicate columns should fail validation");
2958        assert!(
2959            errors
2960                .iter()
2961                .any(|err| err.contains("duplicate column 'email'")),
2962            "{errors:?}"
2963        );
2964    }
2965
2966    #[test]
2967    fn test_validate_rejects_duplicate_index_names() {
2968        let mut schema = Schema::new();
2969        schema.add_table(Table::new("users").column(Column::new("email", ColumnType::Text)));
2970        schema.add_index(Index::new(
2971            "idx_users_email",
2972            "users",
2973            vec!["email".to_string()],
2974        ));
2975        schema.add_index(Index::new(
2976            "idx_users_email",
2977            "users",
2978            vec!["email".to_string()],
2979        ));
2980
2981        let errors = schema
2982            .validate()
2983            .expect_err("duplicate indexes should fail validation");
2984        assert!(
2985            errors
2986                .iter()
2987                .any(|err| err.contains("duplicate index name 'idx_users_email'")),
2988            "{errors:?}"
2989        );
2990    }
2991
2992    #[test]
2993    fn test_validate_rejects_check_on_missing_column() {
2994        let mut schema = Schema::new();
2995        schema.add_table(Table::new("orders").column(
2996            Column::new("status", ColumnType::Text).check(CheckExpr::In {
2997                column: "missing_status".to_string(),
2998                values: vec!["paid".to_string(), "pending".to_string()],
2999            }),
3000        ));
3001
3002        let errors = schema
3003            .validate()
3004            .expect_err("CHECK references should fail validation");
3005        assert!(
3006            errors.iter().any(|err| {
3007                err.contains("CHECK error")
3008                    && err.contains("orders.status")
3009                    && err.contains("orders.missing_status")
3010            }),
3011            "{errors:?}"
3012        );
3013    }
3014
3015    #[test]
3016    fn test_validate_rejects_nested_check_on_missing_column() {
3017        let mut schema = Schema::new();
3018        schema.add_table(
3019            Table::new("pricing_plans")
3020                .column(Column::new("start_date", ColumnType::Date))
3021                .column(
3022                    Column::new("end_date", ColumnType::Date).check(CheckExpr::And(
3023                        Box::new(CheckExpr::CompareColumns {
3024                            left_column: "end_date".to_string(),
3025                            op: CheckComparisonOp::GreaterOrEqual,
3026                            right_column: "start_date".to_string(),
3027                        }),
3028                        Box::new(CheckExpr::CompareColumnToCoalesce {
3029                            left_column: "end_date".to_string(),
3030                            op: CheckComparisonOp::GreaterOrEqual,
3031                            coalesce_column: "missing_fallback_date".to_string(),
3032                            fallback: "1970-01-01".to_string(),
3033                            fallback_cast: Some("date".to_string()),
3034                        }),
3035                    )),
3036                ),
3037        );
3038
3039        let errors = schema
3040            .validate()
3041            .expect_err("nested CHECK references should fail validation");
3042        assert!(
3043            errors
3044                .iter()
3045                .any(|err| err.contains("pricing_plans.missing_fallback_date")),
3046            "{errors:?}"
3047        );
3048    }
3049
3050    #[test]
3051    fn test_validate_rejects_duplicate_check_constraint_names() {
3052        let mut schema = Schema::new();
3053        schema.add_table(
3054            Table::new("orders")
3055                .column(Column::new("status", ColumnType::Text).check_named(
3056                    "orders_status_check",
3057                    CheckExpr::In {
3058                        column: "status".to_string(),
3059                        values: vec!["pending".to_string(), "paid".to_string()],
3060                    },
3061                ))
3062                .column(Column::new("payment_status", ColumnType::Text).check_named(
3063                    "orders_status_check",
3064                    CheckExpr::In {
3065                        column: "payment_status".to_string(),
3066                        values: vec!["pending".to_string(), "paid".to_string()],
3067                    },
3068                )),
3069        );
3070
3071        let errors = schema
3072            .validate()
3073            .expect_err("duplicate constraint names should fail validation");
3074        assert!(
3075            errors
3076                .iter()
3077                .any(|err| { err.contains("duplicate constraint name 'orders_status_check'") }),
3078            "{errors:?}"
3079        );
3080    }
3081
3082    #[test]
3083    fn test_validate_rejects_duplicate_check_and_fk_constraint_names() {
3084        let mut schema = Schema::new();
3085        schema.add_table(
3086            Table::new("schedules")
3087                .column(Column::new("route_id", ColumnType::Text))
3088                .column(Column::new("schedule_id", ColumnType::Text)),
3089        );
3090        schema.add_index(
3091            Index::new(
3092                "schedules_route_schedule_key",
3093                "schedules",
3094                vec!["route_id".to_string(), "schedule_id".to_string()],
3095            )
3096            .unique(),
3097        );
3098        schema.add_table(
3099            Table::new("trips")
3100                .column(Column::new("route_id", ColumnType::Text).check_named(
3101                    "trips_schedule_guard",
3102                    CheckExpr::NotNull {
3103                        column: "route_id".to_string(),
3104                    },
3105                ))
3106                .column(Column::new("schedule_id", ColumnType::Text))
3107                .foreign_key(
3108                    MultiColumnForeignKey::new(
3109                        vec!["route_id".to_string(), "schedule_id".to_string()],
3110                        "schedules",
3111                        vec!["route_id".to_string(), "schedule_id".to_string()],
3112                    )
3113                    .named("trips_schedule_guard"),
3114                ),
3115        );
3116
3117        let errors = schema
3118            .validate()
3119            .expect_err("duplicate constraint names across constraint kinds should fail");
3120        assert!(
3121            errors
3122                .iter()
3123                .any(|err| { err.contains("duplicate constraint name 'trips_schedule_guard'") }),
3124            "{errors:?}"
3125        );
3126    }
3127
3128    #[test]
3129    fn test_validate_rejects_empty_constraint_names() {
3130        let mut schema = Schema::new();
3131        schema.add_table(Table::new("orders").column(
3132            Column::new("status", ColumnType::Text).check_named(
3133                " ",
3134                CheckExpr::NotNull {
3135                    column: "status".to_string(),
3136                },
3137            ),
3138        ));
3139
3140        let errors = schema
3141            .validate()
3142            .expect_err("empty constraint names should fail validation");
3143        assert!(
3144            errors
3145                .iter()
3146                .any(|err| err.contains("empty CHECK constraint name")),
3147            "{errors:?}"
3148        );
3149    }
3150
3151    #[test]
3152    fn test_validate_rejects_index_on_missing_table_or_column() {
3153        let mut schema = Schema::new();
3154        schema.add_table(Table::new("users").column(Column::new("email", ColumnType::Text)));
3155        schema.add_index(Index::new(
3156            "idx_missing_table",
3157            "profiles",
3158            vec!["email".to_string()],
3159        ));
3160        schema.add_index(Index::new(
3161            "idx_missing_column",
3162            "users",
3163            vec!["username".to_string()],
3164        ));
3165
3166        let errors = schema
3167            .validate()
3168            .expect_err("invalid indexes should fail validation");
3169        assert!(
3170            errors
3171                .iter()
3172                .any(|err| err.contains("idx_missing_table") && err.contains("profiles")),
3173            "{errors:?}"
3174        );
3175        assert!(
3176            errors
3177                .iter()
3178                .any(|err| err.contains("idx_missing_column") && err.contains("users.username")),
3179            "{errors:?}"
3180        );
3181    }
3182
3183    #[test]
3184    fn test_validate_rejects_empty_index_definition() {
3185        let mut schema = Schema::new();
3186        schema.add_table(Table::new("users").column(Column::new("email", ColumnType::Text)));
3187        schema.add_index(Index::new("idx_users_empty", "users", vec![]));
3188
3189        let errors = schema
3190            .validate()
3191            .expect_err("empty index definitions should fail validation");
3192        assert!(
3193            errors.iter().any(|err| {
3194                err.contains("idx_users_empty") && err.contains("at least one column or expression")
3195            }),
3196            "{errors:?}"
3197        );
3198    }
3199
3200    #[test]
3201    fn test_validate_rejects_blank_index_column_fragment() {
3202        let mut schema = Schema::new();
3203        schema.add_table(Table::new("users").column(Column::new("email", ColumnType::Text)));
3204        schema.add_index(Index::new(
3205            "idx_users_blank",
3206            "users",
3207            vec![" ".to_string()],
3208        ));
3209
3210        let errors = schema
3211            .validate()
3212            .expect_err("blank index columns should fail validation");
3213        assert!(
3214            errors
3215                .iter()
3216                .any(|err| err.contains("idx_users_blank") && err.contains("empty column")),
3217            "{errors:?}"
3218        );
3219    }
3220
3221    #[test]
3222    fn test_validate_rejects_mixed_index_columns_and_expressions() {
3223        let mut schema = Schema::new();
3224        schema.add_table(Table::new("users").column(Column::new("email", ColumnType::Text)));
3225        let mut index = Index::expression(
3226            "idx_users_email_lower",
3227            "users",
3228            vec!["lower(email)".to_string()],
3229        );
3230        index.columns.push("email".to_string());
3231        schema.add_index(index);
3232
3233        let errors = schema
3234            .validate()
3235            .expect_err("mixed index keys should fail validation");
3236        assert!(
3237            errors.iter().any(|err| {
3238                err.contains("idx_users_email_lower")
3239                    && err.contains("cannot mix columns and expressions")
3240            }),
3241            "{errors:?}"
3242        );
3243    }
3244
3245    #[test]
3246    fn test_validate_rejects_missing_index_include_column() {
3247        let mut schema = Schema::new();
3248        schema.add_table(
3249            Table::new("users")
3250                .column(Column::new("email", ColumnType::Text))
3251                .column(Column::new("created_at", ColumnType::Timestamp)),
3252        );
3253        schema.add_index(
3254            Index::new("idx_users_email_cover", "users", vec!["email".to_string()])
3255                .include(vec!["name".to_string()]),
3256        );
3257
3258        let errors = schema
3259            .validate()
3260            .expect_err("invalid INCLUDE column should fail validation");
3261        assert!(
3262            errors
3263                .iter()
3264                .any(|err| { err.contains("idx_users_email_cover") && err.contains("users.name") }),
3265            "{errors:?}"
3266        );
3267    }
3268
3269    #[test]
3270    fn test_validate_rejects_missing_partial_index_predicate_column() {
3271        let mut schema = Schema::new();
3272        schema.add_table(Table::new("users").column(Column::new("email", ColumnType::Text)));
3273        schema.add_index(
3274            Index::new("idx_users_active_email", "users", vec!["email".to_string()]).partial(
3275                CheckExpr::NotNull {
3276                    column: "deleted_at".to_string(),
3277                },
3278            ),
3279        );
3280
3281        let errors = schema
3282            .validate()
3283            .expect_err("invalid partial-index predicates should fail validation");
3284        assert!(
3285            errors.iter().any(|err| {
3286                err.contains("idx_users_active_email") && err.contains("users.deleted_at")
3287            }),
3288            "{errors:?}"
3289        );
3290    }
3291
3292    #[test]
3293    fn test_validate_allows_index_sort_direction_and_opclass_columns() {
3294        let mut schema = Schema::new();
3295        schema.add_table(
3296            Table::new("documents")
3297                .column(Column::new(
3298                    "embedding",
3299                    ColumnType::Array(Box::new(ColumnType::Float)),
3300                ))
3301                .column(Column::new("created_at", ColumnType::Timestamptz)),
3302        );
3303        schema.add_index(
3304            Index::new(
3305                "idx_docs_embedding_hnsw",
3306                "documents",
3307                vec!["embedding vector_l2_ops".to_string()],
3308            )
3309            .using(IndexMethod::Hnsw),
3310        );
3311        schema.add_index(Index::new(
3312            "idx_docs_created_at",
3313            "documents",
3314            vec!["created_at DESC NULLS LAST".to_string()],
3315        ));
3316
3317        assert!(schema.validate().is_ok());
3318    }
3319
3320    #[test]
3321    fn test_schema_to_commands_preserves_fk_actions_and_checks() {
3322        let mut schema = Schema::new();
3323        schema.add_table(
3324            Table::new("orgs").column(Column::new("id", ColumnType::Uuid).primary_key()),
3325        );
3326        schema.add_table(
3327            Table::new("users")
3328                .column(Column::new("id", ColumnType::Uuid).primary_key())
3329                .column(
3330                    Column::new("org_id", ColumnType::Uuid)
3331                        .references("orgs", "id")
3332                        .on_delete(FkAction::Cascade)
3333                        .on_update(FkAction::Restrict),
3334                )
3335                .column(
3336                    Column::new("age", ColumnType::Int).check(CheckExpr::GreaterOrEqual {
3337                        column: "age".to_string(),
3338                        value: 18,
3339                    }),
3340                ),
3341        );
3342
3343        let cmds = schema_to_commands(&schema);
3344        let users_cmd = cmds
3345            .iter()
3346            .find(|c| c.action == crate::ast::Action::Make && c.table == "users")
3347            .expect("users create command should exist");
3348        let org_id_constraints = users_cmd
3349            .columns
3350            .iter()
3351            .find_map(|e| match e {
3352                crate::ast::Expr::Def {
3353                    name, constraints, ..
3354                } if name == "org_id" => Some(constraints),
3355                _ => None,
3356            })
3357            .expect("org_id should exist");
3358        let age_constraints = users_cmd
3359            .columns
3360            .iter()
3361            .find_map(|e| match e {
3362                crate::ast::Expr::Def {
3363                    name, constraints, ..
3364                } if name == "age" => Some(constraints),
3365                _ => None,
3366            })
3367            .expect("age should exist");
3368
3369        assert!(
3370            org_id_constraints.iter().any(|c| matches!(
3371                c,
3372                crate::ast::Constraint::References(target)
3373                if target.contains("orgs(id)")
3374                    && target.contains("ON DELETE CASCADE")
3375                    && target.contains("ON UPDATE RESTRICT")
3376            )),
3377            "foreign key action clauses should be preserved"
3378        );
3379        assert!(
3380            age_constraints
3381                .iter()
3382                .any(|c| matches!(c, crate::ast::Constraint::Check(vals) if vals.len() == 1)),
3383            "check expressions should be preserved"
3384        );
3385    }
3386
3387    #[test]
3388    fn schema_to_commands_preserves_table_rls_flags() {
3389        let mut docs = Table::new("docs").column(Column::new("id", ColumnType::Uuid).primary_key());
3390        docs.enable_rls = true;
3391        docs.force_rls = true;
3392
3393        let mut schema = Schema::new();
3394        schema.add_table(docs);
3395
3396        let cmds = schema_to_commands(&schema);
3397        let make_idx = cmds
3398            .iter()
3399            .position(|cmd| cmd.action == crate::ast::Action::Make && cmd.table == "docs")
3400            .expect("table create command should exist");
3401        let enable_idx = cmds
3402            .iter()
3403            .position(|cmd| cmd.action == crate::ast::Action::AlterEnableRls && cmd.table == "docs")
3404            .expect("enable RLS command should exist");
3405        let force_idx = cmds
3406            .iter()
3407            .position(|cmd| cmd.action == crate::ast::Action::AlterForceRls && cmd.table == "docs")
3408            .expect("force RLS command should exist");
3409
3410        assert!(make_idx < enable_idx);
3411        assert!(enable_idx < force_idx);
3412    }
3413
3414    #[test]
3415    fn schema_to_commands_preserves_multi_column_foreign_keys() {
3416        use crate::transpiler::ToSql;
3417
3418        let mut schema = Schema::new();
3419        schema.add_table(
3420            Table::new("schedules")
3421                .column(Column::new("route_id", ColumnType::Text))
3422                .column(Column::new("schedule_id", ColumnType::Text)),
3423        );
3424        schema.add_index(
3425            Index::new(
3426                "idx_schedules_route_schedule",
3427                "schedules",
3428                vec!["route_id".to_string(), "schedule_id".to_string()],
3429            )
3430            .unique(),
3431        );
3432        schema.add_table(
3433            Table::new("trips")
3434                .column(Column::new("route_id", ColumnType::Text))
3435                .column(Column::new("schedule_id", ColumnType::Text))
3436                .foreign_key(
3437                    MultiColumnForeignKey::new(
3438                        vec!["route_id".to_string(), "schedule_id".to_string()],
3439                        "schedules",
3440                        vec!["route_id".to_string(), "schedule_id".to_string()],
3441                    )
3442                    .named("fk_trips_schedule")
3443                    .on_delete(FkAction::Cascade)
3444                    .on_update(FkAction::Restrict)
3445                    .initially_deferred(),
3446                ),
3447        );
3448
3449        let cmds = schema_to_commands(&schema);
3450        let schedules_idx = cmds
3451            .iter()
3452            .position(|c| c.action == crate::ast::Action::Make && c.table == "schedules")
3453            .expect("schedules create command should exist");
3454        let trips_idx = cmds
3455            .iter()
3456            .position(|c| c.action == crate::ast::Action::Make && c.table == "trips")
3457            .expect("trips create command should exist");
3458        let unique_idx = cmds
3459            .iter()
3460            .position(|c| {
3461                c.action == crate::ast::Action::Index
3462                    && c.index_def
3463                        .as_ref()
3464                        .is_some_and(|idx| idx.name == "idx_schedules_route_schedule")
3465            })
3466            .expect("unique index command should exist");
3467        let add_fk_idx = cmds
3468            .iter()
3469            .position(|c| c.action == crate::ast::Action::Alter && c.table == "trips")
3470            .expect("trips composite foreign key ALTER command should exist");
3471
3472        assert!(schedules_idx < unique_idx);
3473        assert!(trips_idx < unique_idx);
3474        assert!(unique_idx < add_fk_idx);
3475
3476        let trips_cmd = cmds
3477            .iter()
3478            .find(|c| c.action == crate::ast::Action::Make && c.table == "trips")
3479            .expect("trips create command should exist");
3480        assert!(
3481            trips_cmd.table_constraints.is_empty(),
3482            "composite foreign keys should not be emitted inline on CREATE TABLE"
3483        );
3484
3485        let add_fk_cmd = &cmds[add_fk_idx];
3486        assert!(
3487            add_fk_cmd
3488                .table_constraints
3489                .iter()
3490                .any(|constraint| matches!(
3491                        constraint,
3492                        crate::ast::TableConstraint::ForeignKey {
3493                            name,
3494                            columns,
3495                            ref_table,
3496                            ref_columns,
3497                            on_delete,
3498                            on_update,
3499                            deferrable,
3500                        } if columns == &["route_id", "schedule_id"]
3501                            && name.as_deref() == Some("fk_trips_schedule")
3502                            && ref_table == "schedules"
3503                            && ref_columns == &["route_id", "schedule_id"]
3504                            && on_delete.as_deref() == Some("CASCADE")
3505                            && on_update.as_deref() == Some("RESTRICT")
3506                            && deferrable.as_deref() == Some("DEFERRABLE INITIALLY DEFERRED")
3507                )),
3508            "multi-column foreign key should be represented in generated commands"
3509        );
3510
3511        let sql = add_fk_cmd.to_sql();
3512        assert!(
3513            sql.contains(
3514                "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"
3515            ),
3516            "generated SQL should include composite foreign key, got: {sql}"
3517        );
3518    }
3519
3520    #[test]
3521    fn test_check_expr_sql_renders_integer_in_and_column_comparison() {
3522        assert_eq!(
3523            check_expr_to_sql(&CheckExpr::InIntegers {
3524                column: "duration_hours".to_string(),
3525                values: vec![8, 10, 12],
3526            }),
3527            "duration_hours IN (8, 10, 12)"
3528        );
3529
3530        assert_eq!(
3531            check_expr_to_sql(&CheckExpr::CompareColumns {
3532                left_column: "origin_harbor_id".to_string(),
3533                op: CheckComparisonOp::NotEqual,
3534                right_column: "destination_harbor_id".to_string(),
3535            }),
3536            "origin_harbor_id <> destination_harbor_id"
3537        );
3538
3539        assert_eq!(
3540            check_expr_to_sql(&CheckExpr::TextCompare {
3541                column: "module".to_string(),
3542                op: CheckComparisonOp::NotEqual,
3543                value: "charter".to_string(),
3544            }),
3545            "module <> 'charter'"
3546        );
3547
3548        assert_eq!(
3549            check_expr_to_sql(&CheckExpr::CompareColumnToCoalesce {
3550                left_column: "start_date".to_string(),
3551                op: CheckComparisonOp::LessOrEqual,
3552                coalesce_column: "end_date".to_string(),
3553                fallback: "2099-12-31".to_string(),
3554                fallback_cast: Some("date".to_string()),
3555            }),
3556            "start_date <= COALESCE(end_date, '2099-12-31'::date)"
3557        );
3558
3559        assert_eq!(
3560            check_expr_to_sql(&CheckExpr::LowerTrimEquals {
3561                column: "slug".to_string(),
3562            }),
3563            "slug = lower(btrim(slug))"
3564        );
3565    }
3566}