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::types::ColumnType;
18use std::collections::HashMap;
19
20/// A complete database schema.
21#[derive(Debug, Clone, Default)]
22pub struct Schema {
23    pub tables: HashMap<String, Table>,
24    pub indexes: Vec<Index>,
25    pub migrations: Vec<MigrationHint>,
26}
27
28#[derive(Debug, Clone)]
29pub struct Table {
30    pub name: String,
31    pub columns: Vec<Column>,
32}
33
34/// A column definition with compile-time type safety.
35#[derive(Debug, Clone)]
36pub struct Column {
37    pub name: String,
38    pub data_type: ColumnType,
39    pub nullable: bool,
40    pub primary_key: bool,
41    pub unique: bool,
42    pub default: Option<String>,
43    pub foreign_key: Option<ForeignKey>,
44    /// CHECK constraint (Phase 1)
45    pub check: Option<CheckConstraint>,
46    /// GENERATED column (Phase 3)
47    pub generated: Option<Generated>,
48}
49
50/// Foreign key reference definition.
51#[derive(Debug, Clone)]
52pub struct ForeignKey {
53    pub table: String,
54    pub column: String,
55    pub on_delete: FkAction,
56    pub on_update: FkAction,
57    /// DEFERRABLE clause (Phase 2)
58    pub deferrable: Deferrable,
59}
60
61/// Foreign key action on DELETE/UPDATE.
62#[derive(Debug, Clone, Default, PartialEq)]
63pub enum FkAction {
64    #[default]
65    NoAction,
66    Cascade,
67    SetNull,
68    SetDefault,
69    Restrict,
70}
71
72#[derive(Debug, Clone)]
73pub struct Index {
74    pub name: String,
75    pub table: String,
76    pub columns: Vec<String>,
77    pub unique: bool,
78    /// Index method (Phase 4): btree, hash, gin, gist, brin
79    pub method: IndexMethod,
80    /// Partial index WHERE clause
81    pub where_clause: Option<CheckExpr>,
82    /// INCLUDE columns (covering index)
83    pub include: Vec<String>,
84    /// CREATE CONCURRENTLY
85    pub concurrently: bool,
86}
87
88#[derive(Debug, Clone)]
89pub enum MigrationHint {
90    /// Rename a column (not delete + add)
91    Rename { from: String, to: String },
92    /// Transform data with expression
93    Transform { expression: String, target: String },
94    /// Drop with confirmation
95    Drop { target: String, confirmed: bool },
96}
97
98// ============================================================================
99// Phase 1: CHECK Constraints (AST-native)
100// ============================================================================
101
102/// CHECK constraint expression (AST-native, no raw SQL)
103#[derive(Debug, Clone)]
104pub enum CheckExpr {
105    /// column > value
106    GreaterThan { column: String, value: i64 },
107    /// column >= value
108    GreaterOrEqual { column: String, value: i64 },
109    /// column < value
110    LessThan { column: String, value: i64 },
111    /// column <= value
112    LessOrEqual { column: String, value: i64 },
113    Between { column: String, low: i64, high: i64 },
114    In { column: String, values: Vec<String> },
115    /// column ~ pattern (regex)
116    Regex { column: String, pattern: String },
117    /// LENGTH(column) <= max
118    MaxLength { column: String, max: usize },
119    /// LENGTH(column) >= min
120    MinLength { column: String, min: usize },
121    NotNull { column: String },
122    And(Box<CheckExpr>, Box<CheckExpr>),
123    Or(Box<CheckExpr>, Box<CheckExpr>),
124    Not(Box<CheckExpr>),
125}
126
127/// CHECK constraint with optional name
128#[derive(Debug, Clone)]
129pub struct CheckConstraint {
130    pub expr: CheckExpr,
131    pub name: Option<String>,
132}
133
134// ============================================================================
135// Phase 2: DEFERRABLE Constraints
136// ============================================================================
137
138/// Constraint deferral mode
139#[derive(Debug, Clone, Default, PartialEq)]
140pub enum Deferrable {
141    #[default]
142    NotDeferrable,
143    Deferrable,
144    InitiallyDeferred,
145    InitiallyImmediate,
146}
147
148// ============================================================================
149// Phase 3: GENERATED Columns
150// ============================================================================
151
152/// GENERATED column type
153#[derive(Debug, Clone)]
154pub enum Generated {
155    /// GENERATED ALWAYS AS (expr) STORED
156    AlwaysStored(String),
157    /// GENERATED ALWAYS AS IDENTITY
158    AlwaysIdentity,
159    /// GENERATED BY DEFAULT AS IDENTITY
160    ByDefaultIdentity,
161}
162
163// ============================================================================
164// Phase 4: Advanced Index Types
165// ============================================================================
166
167/// Index method (USING clause)
168#[derive(Debug, Clone, Default, PartialEq)]
169pub enum IndexMethod {
170    #[default]
171    BTree,
172    Hash,
173    Gin,
174    Gist,
175    Brin,
176    SpGist,
177}
178
179impl Schema {
180    pub fn new() -> Self {
181        Self::default()
182    }
183
184    pub fn add_table(&mut self, table: Table) {
185        self.tables.insert(table.name.clone(), table);
186    }
187
188    pub fn add_index(&mut self, index: Index) {
189        self.indexes.push(index);
190    }
191
192    pub fn add_hint(&mut self, hint: MigrationHint) {
193        self.migrations.push(hint);
194    }
195
196    /// Validate all foreign key references in the schema.
197    pub fn validate(&self) -> Result<(), Vec<String>> {
198        let mut errors = Vec::new();
199
200        for table in self.tables.values() {
201            for col in &table.columns {
202                if let Some(ref fk) = col.foreign_key {
203                    if !self.tables.contains_key(&fk.table) {
204                        errors.push(format!(
205                            "FK error: {}.{} references non-existent table '{}'",
206                            table.name, col.name, fk.table
207                        ));
208                    } else {
209                        let ref_table = &self.tables[&fk.table];
210                        if !ref_table.columns.iter().any(|c| c.name == fk.column) {
211                            errors.push(format!(
212                                "FK error: {}.{} references non-existent column '{}.{}'",
213                                table.name, col.name, fk.table, fk.column
214                            ));
215                        }
216                    }
217                }
218            }
219        }
220
221        if errors.is_empty() {
222            Ok(())
223        } else {
224            Err(errors)
225        }
226    }
227}
228
229impl Table {
230    pub fn new(name: impl Into<String>) -> Self {
231        Self {
232            name: name.into(),
233            columns: Vec::new(),
234        }
235    }
236
237    pub fn column(mut self, col: Column) -> Self {
238        self.columns.push(col);
239        self
240    }
241}
242
243impl Column {
244    /// Create a new column with compile-time type validation.
245    pub fn new(name: impl Into<String>, data_type: ColumnType) -> Self {
246        Self {
247            name: name.into(),
248            data_type,
249            nullable: true,
250            primary_key: false,
251            unique: false,
252            default: None,
253            foreign_key: None,
254            check: None,
255            generated: None,
256        }
257    }
258
259    pub fn not_null(mut self) -> Self {
260        self.nullable = false;
261        self
262    }
263
264    /// Set as primary key with compile-time validation.
265    /// Validates that the column type can be a primary key.
266    /// Panics at runtime if type doesn't support PK (caught in tests).
267    pub fn primary_key(mut self) -> Self {
268        if !self.data_type.can_be_primary_key() {
269            panic!(
270                "Column '{}' of type {} cannot be a primary key. \
271                 Valid PK types: UUID, SERIAL, BIGSERIAL, INT, BIGINT",
272                self.name,
273                self.data_type.name()
274            );
275        }
276        self.primary_key = true;
277        self.nullable = false;
278        self
279    }
280
281    /// Set as unique with compile-time validation.
282    /// Validates that the column type supports indexing.
283    pub fn unique(mut self) -> Self {
284        if !self.data_type.supports_indexing() {
285            panic!(
286                "Column '{}' of type {} cannot have UNIQUE constraint. \
287                 JSONB and BYTEA types do not support standard indexing.",
288                self.name,
289                self.data_type.name()
290            );
291        }
292        self.unique = true;
293        self
294    }
295
296    pub fn default(mut self, val: impl Into<String>) -> Self {
297        self.default = Some(val.into());
298        self
299    }
300
301    /// Add a foreign key reference to another table.
302    /// # Example
303    /// ```ignore
304    /// Column::new("user_id", ColumnType::Uuid)
305    ///     .references("users", "id")
306    ///     .on_delete(FkAction::Cascade)
307    /// ```
308    pub fn references(mut self, table: &str, column: &str) -> Self {
309        self.foreign_key = Some(ForeignKey {
310            table: table.to_string(),
311            column: column.to_string(),
312            on_delete: FkAction::default(),
313            on_update: FkAction::default(),
314            deferrable: Deferrable::default(),
315        });
316        self
317    }
318
319    /// Set the ON DELETE action for the foreign key.
320    pub fn on_delete(mut self, action: FkAction) -> Self {
321        if let Some(ref mut fk) = self.foreign_key {
322            fk.on_delete = action;
323        }
324        self
325    }
326
327    /// Set the ON UPDATE action for the foreign key.
328    pub fn on_update(mut self, action: FkAction) -> Self {
329        if let Some(ref mut fk) = self.foreign_key {
330            fk.on_update = action;
331        }
332        self
333    }
334
335    // ==================== Phase 1: CHECK ====================
336
337    /// Add a CHECK constraint (AST-native)
338    pub fn check(mut self, expr: CheckExpr) -> Self {
339        self.check = Some(CheckConstraint { expr, name: None });
340        self
341    }
342
343    /// Add a named CHECK constraint
344    pub fn check_named(mut self, name: impl Into<String>, expr: CheckExpr) -> Self {
345        self.check = Some(CheckConstraint {
346            expr,
347            name: Some(name.into()),
348        });
349        self
350    }
351
352    // ==================== Phase 2: DEFERRABLE ====================
353
354    /// Make foreign key DEFERRABLE
355    pub fn deferrable(mut self) -> Self {
356        if let Some(ref mut fk) = self.foreign_key {
357            fk.deferrable = Deferrable::Deferrable;
358        }
359        self
360    }
361
362    /// Make foreign key DEFERRABLE INITIALLY DEFERRED
363    pub fn initially_deferred(mut self) -> Self {
364        if let Some(ref mut fk) = self.foreign_key {
365            fk.deferrable = Deferrable::InitiallyDeferred;
366        }
367        self
368    }
369
370    /// Make foreign key DEFERRABLE INITIALLY IMMEDIATE
371    pub fn initially_immediate(mut self) -> Self {
372        if let Some(ref mut fk) = self.foreign_key {
373            fk.deferrable = Deferrable::InitiallyImmediate;
374        }
375        self
376    }
377
378    // ==================== Phase 3: GENERATED ====================
379
380    /// GENERATED ALWAYS AS (expr) STORED
381    pub fn generated_stored(mut self, expr: impl Into<String>) -> Self {
382        self.generated = Some(Generated::AlwaysStored(expr.into()));
383        self
384    }
385
386    /// GENERATED ALWAYS AS IDENTITY
387    pub fn generated_identity(mut self) -> Self {
388        self.generated = Some(Generated::AlwaysIdentity);
389        self
390    }
391
392    /// GENERATED BY DEFAULT AS IDENTITY
393    pub fn generated_by_default(mut self) -> Self {
394        self.generated = Some(Generated::ByDefaultIdentity);
395        self
396    }
397}
398
399impl Index {
400    pub fn new(name: impl Into<String>, table: impl Into<String>, columns: Vec<String>) -> Self {
401        Self {
402            name: name.into(),
403            table: table.into(),
404            columns,
405            unique: false,
406            method: IndexMethod::default(),
407            where_clause: None,
408            include: Vec::new(),
409            concurrently: false,
410        }
411    }
412
413    pub fn unique(mut self) -> Self {
414        self.unique = true;
415        self
416    }
417
418    // ==================== Phase 4: Advanced Index Options ====================
419
420    /// Set index method (USING clause)
421    pub fn using(mut self, method: IndexMethod) -> Self {
422        self.method = method;
423        self
424    }
425
426    /// Create a partial index with WHERE clause
427    pub fn partial(mut self, expr: CheckExpr) -> Self {
428        self.where_clause = Some(expr);
429        self
430    }
431
432    /// Add INCLUDE columns (covering index)
433    pub fn include(mut self, cols: Vec<String>) -> Self {
434        self.include = cols;
435        self
436    }
437
438    /// Create index CONCURRENTLY
439    pub fn concurrently(mut self) -> Self {
440        self.concurrently = true;
441        self
442    }
443}
444
445/// Format a Schema to .qail format string.
446pub fn to_qail_string(schema: &Schema) -> String {
447    let mut output = String::new();
448    output.push_str("# QAIL Schema\n\n");
449
450    for table in schema.tables.values() {
451        output.push_str(&format!("table {} {{\n", table.name));
452        for col in &table.columns {
453            let mut constraints: Vec<String> = Vec::new();
454            if col.primary_key {
455                constraints.push("primary_key".to_string());
456            }
457            if !col.nullable && !col.primary_key {
458                constraints.push("not_null".to_string());
459            }
460            if col.unique {
461                constraints.push("unique".to_string());
462            }
463            if let Some(def) = &col.default {
464                constraints.push(format!("default {}", def));
465            }
466            if let Some(ref fk) = col.foreign_key {
467                constraints.push(format!("references {}({})", fk.table, fk.column));
468            }
469
470            let constraint_str = if constraints.is_empty() {
471                String::new()
472            } else {
473                format!(" {}", constraints.join(" "))
474            };
475
476            output.push_str(&format!(
477                "  {} {}{}\n",
478                col.name,
479                col.data_type.to_pg_type(),
480                constraint_str
481            ));
482        }
483        output.push_str("}\n\n");
484    }
485
486    for idx in &schema.indexes {
487        let unique = if idx.unique { "unique " } else { "" };
488        output.push_str(&format!(
489            "{}index {} on {} ({})\n",
490            unique,
491            idx.name,
492            idx.table,
493            idx.columns.join(", ")
494        ));
495    }
496
497    for hint in &schema.migrations {
498        match hint {
499            MigrationHint::Rename { from, to } => {
500                output.push_str(&format!("rename {} -> {}\n", from, to));
501            }
502            MigrationHint::Transform { expression, target } => {
503                output.push_str(&format!("transform {} -> {}\n", expression, target));
504            }
505            MigrationHint::Drop { target, confirmed } => {
506                let confirm = if *confirmed { " confirm" } else { "" };
507                output.push_str(&format!("drop {}{}\n", target, confirm));
508            }
509        }
510    }
511
512    output
513}
514
515/// Convert a Schema to a list of Qail commands (CREATE TABLE, CREATE INDEX).
516/// Used by shadow migration to apply the base schema before applying diffs.
517pub fn schema_to_commands(schema: &Schema) -> Vec<crate::ast::Qail> {
518    use crate::ast::{Action, Constraint, Expr, IndexDef, Qail};
519    
520    let mut cmds = Vec::new();
521    
522    // Sort tables to handle dependencies (tables with FK refs should come after their targets)
523    let mut table_order: Vec<&Table> = schema.tables.values().collect();
524    table_order.sort_by(|a, b| {
525        let a_has_fk = a.columns.iter().any(|c| c.foreign_key.is_some());
526        let b_has_fk = b.columns.iter().any(|c| c.foreign_key.is_some());
527        a_has_fk.cmp(&b_has_fk)
528    });
529    
530    for table in table_order {
531        // Build columns using Expr::Def exactly like diff.rs does
532        let columns: Vec<Expr> = table.columns.iter().map(|col| {
533            let mut constraints = Vec::new();
534            
535            if col.primary_key {
536                constraints.push(Constraint::PrimaryKey);
537            }
538            if col.nullable {
539                constraints.push(Constraint::Nullable);
540            }
541            if col.unique {
542                constraints.push(Constraint::Unique);
543            }
544            if let Some(def) = &col.default {
545                constraints.push(Constraint::Default(def.clone()));
546            }
547            if let Some(ref fk) = col.foreign_key {
548                constraints.push(Constraint::References(format!(
549                    "{}({})",
550                    fk.table, fk.column
551                )));
552            }
553            
554            Expr::Def {
555                name: col.name.clone(),
556                data_type: col.data_type.to_pg_type(),
557                constraints,
558            }
559        }).collect();
560        
561        cmds.push(Qail {
562            action: Action::Make,
563            table: table.name.clone(),
564            columns,
565            ..Default::default()
566        });
567    }
568    
569    // Add indexes using IndexDef like diff.rs
570    for idx in &schema.indexes {
571        cmds.push(Qail {
572            action: Action::Index,
573            table: String::new(),
574            index_def: Some(IndexDef {
575                name: idx.name.clone(),
576                table: idx.table.clone(),
577                columns: idx.columns.clone(),
578                unique: idx.unique,
579                index_type: None,
580            }),
581            ..Default::default()
582        });
583    }
584    
585    cmds
586}
587
588#[cfg(test)]
589mod tests {
590    use super::*;
591
592    #[test]
593    fn test_schema_builder() {
594        let mut schema = Schema::new();
595
596        let users = Table::new("users")
597            .column(Column::new("id", ColumnType::Serial).primary_key())
598            .column(Column::new("name", ColumnType::Text).not_null())
599            .column(Column::new("email", ColumnType::Text).unique());
600
601        schema.add_table(users);
602        schema.add_index(Index::new("idx_users_email", "users", vec!["email".into()]).unique());
603
604        let output = to_qail_string(&schema);
605        assert!(output.contains("table users"));
606        assert!(output.contains("id SERIAL primary_key"));
607        assert!(output.contains("unique index idx_users_email"));
608    }
609
610    #[test]
611    fn test_migration_hints() {
612        let mut schema = Schema::new();
613        schema.add_hint(MigrationHint::Rename {
614            from: "users.username".into(),
615            to: "users.name".into(),
616        });
617
618        let output = to_qail_string(&schema);
619        assert!(output.contains("rename users.username -> users.name"));
620    }
621
622    #[test]
623    #[should_panic(expected = "cannot be a primary key")]
624    fn test_invalid_primary_key_type() {
625        // TEXT cannot be a primary key
626        Column::new("data", ColumnType::Text).primary_key();
627    }
628
629    #[test]
630    #[should_panic(expected = "cannot have UNIQUE")]
631    fn test_invalid_unique_type() {
632        // JSONB cannot have standard unique index
633        Column::new("data", ColumnType::Jsonb).unique();
634    }
635
636    #[test]
637    fn test_foreign_key_valid() {
638        let mut schema = Schema::new();
639
640        schema.add_table(
641            Table::new("users").column(Column::new("id", ColumnType::Uuid).primary_key()),
642        );
643
644        schema.add_table(
645            Table::new("posts")
646                .column(Column::new("id", ColumnType::Uuid).primary_key())
647                .column(
648                    Column::new("user_id", ColumnType::Uuid)
649                        .references("users", "id")
650                        .on_delete(FkAction::Cascade),
651                ),
652        );
653
654        // Should pass validation
655        assert!(schema.validate().is_ok());
656    }
657
658    #[test]
659    fn test_foreign_key_invalid_table() {
660        let mut schema = Schema::new();
661
662        schema.add_table(
663            Table::new("posts")
664                .column(Column::new("id", ColumnType::Uuid).primary_key())
665                .column(Column::new("user_id", ColumnType::Uuid).references("nonexistent", "id")),
666        );
667
668        // Should fail validation
669        let result = schema.validate();
670        assert!(result.is_err());
671        assert!(result.unwrap_err()[0].contains("non-existent table"));
672    }
673
674    #[test]
675    fn test_foreign_key_invalid_column() {
676        let mut schema = Schema::new();
677
678        schema.add_table(
679            Table::new("users").column(Column::new("id", ColumnType::Uuid).primary_key()),
680        );
681
682        schema.add_table(
683            Table::new("posts")
684                .column(Column::new("id", ColumnType::Uuid).primary_key())
685                .column(
686                    Column::new("user_id", ColumnType::Uuid).references("users", "wrong_column"),
687                ),
688        );
689
690        // Should fail validation
691        let result = schema.validate();
692        assert!(result.is_err());
693        assert!(result.unwrap_err()[0].contains("non-existent column"));
694    }
695}