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