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 std::collections::HashMap;
18use super::types::ColumnType;
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}
46
47/// Foreign key reference definition.
48#[derive(Debug, Clone)]
49pub struct ForeignKey {
50    pub table: String,
51    pub column: String,
52    pub on_delete: FkAction,
53    pub on_update: FkAction,
54}
55
56/// Foreign key action on DELETE/UPDATE.
57#[derive(Debug, Clone, Default, PartialEq)]
58pub enum FkAction {
59    #[default]
60    NoAction,
61    Cascade,
62    SetNull,
63    SetDefault,
64    Restrict,
65}
66
67/// An index definition.
68#[derive(Debug, Clone)]
69pub struct Index {
70    pub name: String,
71    pub table: String,
72    pub columns: Vec<String>,
73    pub unique: bool,
74}
75
76/// Migration hints (intent-aware).
77#[derive(Debug, Clone)]
78pub enum MigrationHint {
79    /// Rename a column (not delete + add)
80    Rename { from: String, to: String },
81    /// Transform data with expression
82    Transform { expression: String, target: String },
83    /// Drop with confirmation
84    Drop { target: String, confirmed: bool },
85}
86
87impl Schema {
88    pub fn new() -> Self {
89        Self::default()
90    }
91
92    pub fn add_table(&mut self, table: Table) {
93        self.tables.insert(table.name.clone(), table);
94    }
95
96    pub fn add_index(&mut self, index: Index) {
97        self.indexes.push(index);
98    }
99
100    pub fn add_hint(&mut self, hint: MigrationHint) {
101        self.migrations.push(hint);
102    }
103    
104    /// Validate all foreign key references in the schema.
105    /// 
106    /// Returns a list of validation errors if any references are invalid.
107    pub fn validate(&self) -> Result<(), Vec<String>> {
108        let mut errors = Vec::new();
109        
110        for table in self.tables.values() {
111            for col in &table.columns {
112                if let Some(ref fk) = col.foreign_key {
113                    // Check referenced table exists
114                    if !self.tables.contains_key(&fk.table) {
115                        errors.push(format!(
116                            "FK error: {}.{} references non-existent table '{}'",
117                            table.name, col.name, fk.table
118                        ));
119                    } else {
120                        // Check referenced column exists
121                        let ref_table = &self.tables[&fk.table];
122                        if !ref_table.columns.iter().any(|c| c.name == fk.column) {
123                            errors.push(format!(
124                                "FK error: {}.{} references non-existent column '{}.{}'",
125                                table.name, col.name, fk.table, fk.column
126                            ));
127                        }
128                    }
129                }
130            }
131        }
132        
133        if errors.is_empty() { Ok(()) } else { Err(errors) }
134    }
135}
136
137impl Table {
138    pub fn new(name: impl Into<String>) -> Self {
139        Self {
140            name: name.into(),
141            columns: Vec::new(),
142        }
143    }
144
145    pub fn column(mut self, col: Column) -> Self {
146        self.columns.push(col);
147        self
148    }
149}
150
151impl Column {
152    /// Create a new column with compile-time type validation.
153    pub fn new(name: impl Into<String>, data_type: ColumnType) -> Self {
154        Self {
155            name: name.into(),
156            data_type,
157            nullable: true,
158            primary_key: false,
159            unique: false,
160            default: None,
161            foreign_key: None,
162        }
163    }
164
165    pub fn not_null(mut self) -> Self {
166        self.nullable = false;
167        self
168    }
169
170    /// Set as primary key with compile-time validation.
171    /// 
172    /// Validates that the column type can be a primary key.
173    /// Panics at runtime if type doesn't support PK (caught in tests).
174    pub fn primary_key(mut self) -> Self {
175        if !self.data_type.can_be_primary_key() {
176            panic!(
177                "Column '{}' of type {} cannot be a primary key. \
178                 Valid PK types: UUID, SERIAL, BIGSERIAL, INT, BIGINT",
179                self.name,
180                self.data_type.name()
181            );
182        }
183        self.primary_key = true;
184        self.nullable = false;
185        self
186    }
187
188    /// Set as unique with compile-time validation.
189    /// 
190    /// Validates that the column type supports indexing.
191    pub fn unique(mut self) -> Self {
192        if !self.data_type.supports_indexing() {
193            panic!(
194                "Column '{}' of type {} cannot have UNIQUE constraint. \
195                 JSONB and BYTEA types do not support standard indexing.",
196                self.name,
197                self.data_type.name()
198            );
199        }
200        self.unique = true;
201        self
202    }
203
204    pub fn default(mut self, val: impl Into<String>) -> Self {
205        self.default = Some(val.into());
206        self
207    }
208    
209    /// Add a foreign key reference to another table.
210    /// 
211    /// # Example
212    /// ```ignore
213    /// Column::new("user_id", ColumnType::Uuid)
214    ///     .references("users", "id")
215    ///     .on_delete(FkAction::Cascade)
216    /// ```
217    pub fn references(mut self, table: &str, column: &str) -> Self {
218        self.foreign_key = Some(ForeignKey {
219            table: table.to_string(),
220            column: column.to_string(),
221            on_delete: FkAction::default(),
222            on_update: FkAction::default(),
223        });
224        self
225    }
226    
227    /// Set the ON DELETE action for the foreign key.
228    pub fn on_delete(mut self, action: FkAction) -> Self {
229        if let Some(ref mut fk) = self.foreign_key {
230            fk.on_delete = action;
231        }
232        self
233    }
234    
235    /// Set the ON UPDATE action for the foreign key.
236    pub fn on_update(mut self, action: FkAction) -> Self {
237        if let Some(ref mut fk) = self.foreign_key {
238            fk.on_update = action;
239        }
240        self
241    }
242}
243
244impl Index {
245    pub fn new(name: impl Into<String>, table: impl Into<String>, columns: Vec<String>) -> Self {
246        Self {
247            name: name.into(),
248            table: table.into(),
249            columns,
250            unique: false,
251        }
252    }
253
254    pub fn unique(mut self) -> Self {
255        self.unique = true;
256        self
257    }
258}
259
260/// Format a Schema to .qail format string.
261pub fn to_qail_string(schema: &Schema) -> String {
262    let mut output = String::new();
263    output.push_str("# QAIL Schema\n\n");
264
265    for table in schema.tables.values() {
266        output.push_str(&format!("table {} {{\n", table.name));
267        for col in &table.columns {
268            let mut constraints: Vec<String> = Vec::new();
269            if col.primary_key {
270                constraints.push("primary_key".to_string());
271            }
272            if !col.nullable && !col.primary_key {
273                constraints.push("not_null".to_string());
274            }
275            if col.unique {
276                constraints.push("unique".to_string());
277            }
278            if let Some(def) = &col.default {
279                constraints.push(format!("default {}", def));
280            }
281            
282            let constraint_str = if constraints.is_empty() {
283                String::new()
284            } else {
285                format!(" {}", constraints.join(" "))
286            };
287            
288            output.push_str(&format!("  {} {}{}\n", col.name, col.data_type.to_pg_type(), constraint_str));
289        }
290        output.push_str("}\n\n");
291    }
292
293    for idx in &schema.indexes {
294        let unique = if idx.unique { "unique " } else { "" };
295        output.push_str(&format!(
296            "{}index {} on {} ({})\n",
297            unique,
298            idx.name,
299            idx.table,
300            idx.columns.join(", ")
301        ));
302    }
303
304    for hint in &schema.migrations {
305        match hint {
306            MigrationHint::Rename { from, to } => {
307                output.push_str(&format!("rename {} -> {}\n", from, to));
308            }
309            MigrationHint::Transform { expression, target } => {
310                output.push_str(&format!("transform {} -> {}\n", expression, target));
311            }
312            MigrationHint::Drop { target, confirmed } => {
313                let confirm = if *confirmed { " confirm" } else { "" };
314                output.push_str(&format!("drop {}{}\n", target, confirm));
315            }
316        }
317    }
318
319    output
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325
326    #[test]
327    fn test_schema_builder() {
328        let mut schema = Schema::new();
329        
330        let users = Table::new("users")
331            .column(Column::new("id", ColumnType::Serial).primary_key())
332            .column(Column::new("name", ColumnType::Text).not_null())
333            .column(Column::new("email", ColumnType::Text).unique());
334        
335        schema.add_table(users);
336        schema.add_index(Index::new("idx_users_email", "users", vec!["email".into()]).unique());
337        
338        let output = to_qail_string(&schema);
339        assert!(output.contains("table users"));
340        assert!(output.contains("id SERIAL primary_key"));
341        assert!(output.contains("unique index idx_users_email"));
342    }
343
344    #[test]
345    fn test_migration_hints() {
346        let mut schema = Schema::new();
347        schema.add_hint(MigrationHint::Rename {
348            from: "users.username".into(),
349            to: "users.name".into(),
350        });
351        
352        let output = to_qail_string(&schema);
353        assert!(output.contains("rename users.username -> users.name"));
354    }
355    
356    #[test]
357    #[should_panic(expected = "cannot be a primary key")]
358    fn test_invalid_primary_key_type() {
359        // TEXT cannot be a primary key
360        Column::new("data", ColumnType::Text).primary_key();
361    }
362    
363    #[test]
364    #[should_panic(expected = "cannot have UNIQUE")]
365    fn test_invalid_unique_type() {
366        // JSONB cannot have standard unique index
367        Column::new("data", ColumnType::Jsonb).unique();
368    }
369    
370    #[test]
371    fn test_foreign_key_valid() {
372        let mut schema = Schema::new();
373        
374        // Add users table first
375        schema.add_table(Table::new("users")
376            .column(Column::new("id", ColumnType::Uuid).primary_key()));
377        
378        // Add posts with valid FK to users
379        schema.add_table(Table::new("posts")
380            .column(Column::new("id", ColumnType::Uuid).primary_key())
381            .column(Column::new("user_id", ColumnType::Uuid)
382                .references("users", "id")
383                .on_delete(FkAction::Cascade)));
384        
385        // Should pass validation
386        assert!(schema.validate().is_ok());
387    }
388    
389    #[test]
390    fn test_foreign_key_invalid_table() {
391        let mut schema = Schema::new();
392        
393        // Add posts with FK to non-existent table
394        schema.add_table(Table::new("posts")
395            .column(Column::new("id", ColumnType::Uuid).primary_key())
396            .column(Column::new("user_id", ColumnType::Uuid)
397                .references("nonexistent", "id")));
398        
399        // Should fail validation
400        let result = schema.validate();
401        assert!(result.is_err());
402        assert!(result.unwrap_err()[0].contains("non-existent table"));
403    }
404    
405    #[test]
406    fn test_foreign_key_invalid_column() {
407        let mut schema = Schema::new();
408        
409        // Add users table
410        schema.add_table(Table::new("users")
411            .column(Column::new("id", ColumnType::Uuid).primary_key()));
412        
413        // Add posts with FK to non-existent column
414        schema.add_table(Table::new("posts")
415            .column(Column::new("id", ColumnType::Uuid).primary_key())
416            .column(Column::new("user_id", ColumnType::Uuid)
417                .references("users", "wrong_column")));
418        
419        // Should fail validation
420        let result = schema.validate();
421        assert!(result.is_err());
422        assert!(result.unwrap_err()[0].contains("non-existent column"));
423    }
424}