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