qail_core/migrate/
diff.rs

1//! Schema Diff Visitor
2//!
3//! Computes the difference between two schemas and generates Qail operations.
4//! Now with intent-awareness from MigrationHint.
5
6use super::schema::{MigrationHint, Schema};
7use crate::ast::{Action, Constraint, Expr, IndexDef, Qail};
8
9/// Compute the difference between two schemas.
10///
11/// Returns a Vec<Qail> representing the operations needed to migrate
12/// from `old` to `new`. Respects MigrationHint for intent-aware diffing.
13pub fn diff_schemas(old: &Schema, new: &Schema) -> Vec<Qail> {
14    let mut cmds = Vec::new();
15
16    // Process migration hints first (intent-aware)
17    for hint in &new.migrations {
18        match hint {
19            MigrationHint::Rename { from, to } => {
20                if let (Some((from_table, from_col)), Some((to_table, to_col))) =
21                    (parse_table_col(from), parse_table_col(to))
22                    && from_table == to_table
23                {
24                    // Same table rename - use ALTER TABLE RENAME COLUMN
25                    cmds.push(Qail {
26                        action: Action::Mod,
27                        table: from_table.to_string(),
28                        columns: vec![Expr::Named(format!("{} -> {}", from_col, to_col))],
29                        ..Default::default()
30                    });
31                }
32            }
33            MigrationHint::Transform { expression, target } => {
34                if let Some((table, _col)) = parse_table_col(target) {
35                    cmds.push(Qail {
36                        action: Action::Set,
37                        table: table.to_string(),
38                        columns: vec![Expr::Named(format!("/* TRANSFORM: {} */", expression))],
39                        ..Default::default()
40                    });
41                }
42            }
43            MigrationHint::Drop {
44                target,
45                confirmed: true,
46            } => {
47                if target.contains('.') {
48                    // Drop column
49                    if let Some((table, col)) = parse_table_col(target) {
50                        cmds.push(Qail {
51                            action: Action::AlterDrop,
52                            table: table.to_string(),
53                            columns: vec![Expr::Named(col.to_string())],
54                            ..Default::default()
55                        });
56                    }
57                } else {
58                    // Drop table
59                    cmds.push(Qail {
60                        action: Action::Drop,
61                        table: target.clone(),
62                        ..Default::default()
63                    });
64                }
65            }
66            _ => {}
67        }
68    }
69
70    // Collect new tables (not in old schema), sorted by FK dependencies
71    let mut new_table_names: Vec<&String> = new
72        .tables
73        .keys()
74        .filter(|name| !old.tables.contains_key(*name))
75        .collect();
76
77    // Simple FK-aware sort: tables with no FK deps first, then others
78    // This handles the common case of parent -> child relationships
79    new_table_names.sort_by_key(|name| {
80        new.tables
81            .get(*name)
82            .map(|t| t.columns.iter().filter(|c| c.foreign_key.is_some()).count())
83            .unwrap_or(0)
84    });
85
86    // Generate CREATE TABLE commands in dependency order
87    for name in new_table_names {
88        let table = &new.tables[name];
89        let columns: Vec<Expr> = table
90            .columns
91            .iter()
92            .map(|col| {
93                let mut constraints = Vec::new();
94                if col.primary_key {
95                    constraints.push(Constraint::PrimaryKey);
96                }
97                if col.nullable {
98                    constraints.push(Constraint::Nullable);
99                }
100                if col.unique {
101                    constraints.push(Constraint::Unique);
102                }
103                if let Some(def) = &col.default {
104                    constraints.push(Constraint::Default(def.clone()));
105                }
106                if let Some(ref fk) = col.foreign_key {
107                    constraints.push(Constraint::References(format!(
108                        "{}({})",
109                        fk.table, fk.column
110                    )));
111                }
112
113                Expr::Def {
114                    name: col.name.clone(),
115                    data_type: col.data_type.to_pg_type(),
116                    constraints,
117                }
118            })
119            .collect();
120
121        cmds.push(Qail {
122            action: Action::Make,
123            table: name.clone(),
124            columns,
125            ..Default::default()
126        });
127    }
128
129    // Detect dropped tables (only if not already handled by hints)
130    for name in old.tables.keys() {
131        if !new.tables.contains_key(name) {
132            let already_dropped = new.migrations.iter().any(
133                |h| matches!(h, MigrationHint::Drop { target, confirmed: true } if target == name),
134            );
135            if !already_dropped {
136                cmds.push(Qail {
137                    action: Action::Drop,
138                    table: name.clone(),
139                    ..Default::default()
140                });
141            }
142        }
143    }
144
145    // Detect column changes in existing tables
146    for (name, new_table) in &new.tables {
147        if let Some(old_table) = old.tables.get(name) {
148            let old_cols: std::collections::HashSet<_> =
149                old_table.columns.iter().map(|c| &c.name).collect();
150            let new_cols: std::collections::HashSet<_> =
151                new_table.columns.iter().map(|c| &c.name).collect();
152
153            // New columns
154            for col in &new_table.columns {
155                if !old_cols.contains(&col.name) {
156                    let is_rename_target = new.migrations.iter().any(|h| {
157                        matches!(h, MigrationHint::Rename { to, .. } if to.ends_with(&format!(".{}", col.name)))
158                    });
159
160                    if !is_rename_target {
161                        let mut constraints = Vec::new();
162                        if col.nullable {
163                            constraints.push(Constraint::Nullable);
164                        }
165                        if col.unique {
166                            constraints.push(Constraint::Unique);
167                        }
168                        if let Some(def) = &col.default {
169                            constraints.push(Constraint::Default(def.clone()));
170                        }
171
172                        cmds.push(Qail {
173                            action: Action::Alter,
174                            table: name.clone(),
175                            columns: vec![Expr::Def {
176                                name: col.name.clone(),
177                                data_type: col.data_type.to_pg_type(),
178                                constraints,
179                            }],
180                            ..Default::default()
181                        });
182                    }
183                }
184            }
185
186            // Dropped columns (not handled by hints)
187            for col in &old_table.columns {
188                if !new_cols.contains(&col.name) {
189                    let is_rename_source = new.migrations.iter().any(|h| {
190                        matches!(h, MigrationHint::Rename { from, .. } if from.ends_with(&format!(".{}", col.name)))
191                    });
192
193                    if !is_rename_source {
194                        cmds.push(Qail {
195                            action: Action::AlterDrop,
196                            table: name.clone(),
197                            columns: vec![Expr::Named(col.name.clone())],
198                            ..Default::default()
199                        });
200                    }
201                }
202            }
203
204            // Detect type changes in existing columns
205            for new_col in &new_table.columns {
206                if let Some(old_col) = old_table.columns.iter().find(|c| c.name == new_col.name) {
207                    let old_type = old_col.data_type.to_pg_type();
208                    let new_type = new_col.data_type.to_pg_type();
209
210                    if old_type != new_type {
211                        // Type changed - ALTER COLUMN TYPE
212                        cmds.push(Qail {
213                            action: Action::AlterType,
214                            table: name.clone(),
215                            columns: vec![Expr::Def {
216                                name: new_col.name.clone(),
217                                data_type: new_type,
218                                constraints: vec![],
219                            }],
220                            ..Default::default()
221                        });
222                    }
223                }
224            }
225        }
226    }
227
228    // Detect new indexes
229    for new_idx in &new.indexes {
230        let exists = old.indexes.iter().any(|i| i.name == new_idx.name);
231        if !exists {
232            cmds.push(Qail {
233                action: Action::Index,
234                table: String::new(),
235                index_def: Some(IndexDef {
236                    name: new_idx.name.clone(),
237                    table: new_idx.table.clone(),
238                    columns: new_idx.columns.clone(),
239                    unique: new_idx.unique,
240                }),
241                ..Default::default()
242            });
243        }
244    }
245
246    // Detect dropped indexes
247    for old_idx in &old.indexes {
248        let exists = new.indexes.iter().any(|i| i.name == old_idx.name);
249        if !exists {
250            cmds.push(Qail {
251                action: Action::DropIndex,
252                table: old_idx.name.clone(),
253                ..Default::default()
254            });
255        }
256    }
257
258    cmds
259}
260
261/// Parse "table.column" format
262fn parse_table_col(s: &str) -> Option<(&str, &str)> {
263    let parts: Vec<&str> = s.splitn(2, '.').collect();
264    if parts.len() == 2 {
265        Some((parts[0], parts[1]))
266    } else {
267        None
268    }
269}
270
271#[cfg(test)]
272mod tests {
273    use super::super::schema::{Column, Table};
274    use super::*;
275
276    #[test]
277    fn test_diff_new_table() {
278        use super::super::types::ColumnType;
279        let old = Schema::default();
280        let mut new = Schema::default();
281        new.add_table(
282            Table::new("users")
283                .column(Column::new("id", ColumnType::Serial).primary_key())
284                .column(Column::new("name", ColumnType::Text).not_null()),
285        );
286
287        let cmds = diff_schemas(&old, &new);
288        assert_eq!(cmds.len(), 1);
289        assert!(matches!(cmds[0].action, Action::Make));
290    }
291
292    #[test]
293    fn test_diff_rename_with_hint() {
294        use super::super::types::ColumnType;
295        let mut old = Schema::default();
296        old.add_table(Table::new("users").column(Column::new("username", ColumnType::Text)));
297
298        let mut new = Schema::default();
299        new.add_table(Table::new("users").column(Column::new("name", ColumnType::Text)));
300        new.add_hint(MigrationHint::Rename {
301            from: "users.username".into(),
302            to: "users.name".into(),
303        });
304
305        let cmds = diff_schemas(&old, &new);
306        // Should have rename, NOT drop + add
307        assert!(cmds.iter().any(|c| matches!(c.action, Action::Mod)));
308        assert!(!cmds.iter().any(|c| matches!(c.action, Action::AlterDrop)));
309    }
310
311    /// Regression test: FK parent tables must be created before child tables
312    #[test]
313    fn test_fk_ordering_parent_before_child() {
314        use super::super::types::ColumnType;
315        
316        let old = Schema::default();
317        
318        let mut new = Schema::default();
319        // Child table with FK to parent
320        new.add_table(
321            Table::new("child")
322                .column(Column::new("id", ColumnType::Serial).primary_key())
323                .column(Column::new("parent_id", ColumnType::Int).references("parent", "id")),
324        );
325        // Parent table (no FK)
326        new.add_table(
327            Table::new("parent")
328                .column(Column::new("id", ColumnType::Serial).primary_key())
329                .column(Column::new("name", ColumnType::Text)),
330        );
331
332        let cmds = diff_schemas(&old, &new);
333        
334        // Should have 2 CREATE TABLE commands
335        let make_cmds: Vec<_> = cmds.iter().filter(|c| matches!(c.action, Action::Make)).collect();
336        assert_eq!(make_cmds.len(), 2);
337        
338        // Parent (0 FKs) should come BEFORE child (1 FK)
339        let parent_idx = make_cmds.iter().position(|c| c.table == "parent").unwrap();
340        let child_idx = make_cmds.iter().position(|c| c.table == "child").unwrap();
341        assert!(parent_idx < child_idx, "parent table should be created before child with FK");
342    }
343
344    /// Regression test: Multiple FK dependencies should be sorted correctly
345    #[test]
346    fn test_fk_ordering_multiple_dependencies() {
347        use super::super::types::ColumnType;
348        
349        let old = Schema::default();
350        
351        let mut new = Schema::default();
352        // Table with 2 FKs (should be last)
353        new.add_table(
354            Table::new("order_items")
355                .column(Column::new("id", ColumnType::Serial).primary_key())
356                .column(Column::new("order_id", ColumnType::Int).references("orders", "id"))
357                .column(Column::new("product_id", ColumnType::Int).references("products", "id")),
358        );
359        // Table with 1 FK (should be middle)
360        new.add_table(
361            Table::new("orders")
362                .column(Column::new("id", ColumnType::Serial).primary_key())
363                .column(Column::new("user_id", ColumnType::Int).references("users", "id")),
364        );
365        // Table with 0 FKs (should be first)
366        new.add_table(
367            Table::new("users")
368                .column(Column::new("id", ColumnType::Serial).primary_key()),
369        );
370        new.add_table(
371            Table::new("products")
372                .column(Column::new("id", ColumnType::Serial).primary_key()),
373        );
374
375        let cmds = diff_schemas(&old, &new);
376        
377        let make_cmds: Vec<_> = cmds.iter().filter(|c| matches!(c.action, Action::Make)).collect();
378        assert_eq!(make_cmds.len(), 4);
379        
380        // Get positions
381        let users_idx = make_cmds.iter().position(|c| c.table == "users").unwrap();
382        let products_idx = make_cmds.iter().position(|c| c.table == "products").unwrap();
383        let orders_idx = make_cmds.iter().position(|c| c.table == "orders").unwrap();
384        let items_idx = make_cmds.iter().position(|c| c.table == "order_items").unwrap();
385        
386        // Tables with 0 FKs should come first
387        assert!(users_idx < orders_idx, "users (0 FK) before orders (1 FK)");
388        assert!(products_idx < items_idx, "products (0 FK) before order_items (2 FK)");
389        
390        // orders (1 FK) should come before order_items (2 FKs)
391        assert!(orders_idx < items_idx, "orders (1 FK) before order_items (2 FK)");
392    }
393}
394