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