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