Skip to main content

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::{
7    Generated, MigrationHint, Schema, check_expr_to_sql, foreign_key_to_sql, index_method_str,
8    multi_column_fk_to_alter_command,
9};
10use crate::ast::{Action, ColumnGeneration, Constraint, Expr, IndexDef, Qail};
11use std::collections::BTreeSet;
12
13/// Return unsupported non-table object families present in a schema.
14///
15/// State-based diff currently covers table/index/migration-hint operations only.
16fn unsupported_state_diff_features(schema: &Schema) -> BTreeSet<&'static str> {
17    let mut out = BTreeSet::new();
18    if !schema.extensions.is_empty() {
19        out.insert("extensions");
20    }
21    if !schema.comments.is_empty() {
22        out.insert("comments");
23    }
24    if !schema.sequences.is_empty() {
25        out.insert("sequences");
26    }
27    if !schema.enums.is_empty() {
28        out.insert("enums");
29    }
30    if !schema.views.is_empty() {
31        out.insert("views");
32    }
33    if !schema.functions.is_empty() {
34        out.insert("functions");
35    }
36    if !schema.triggers.is_empty() {
37        out.insert("triggers");
38    }
39    if !schema.grants.is_empty() {
40        out.insert("grants");
41    }
42    if !schema.policies.is_empty() {
43        out.insert("policies");
44    }
45    if !schema.resources.is_empty() {
46        out.insert("resources");
47    }
48    out
49}
50
51fn existing_column_check_diffs(old: &Schema, new: &Schema) -> Vec<String> {
52    let mut changes = Vec::new();
53
54    for (table_name, new_table) in &new.tables {
55        let Some(old_table) = old.tables.get(table_name) else {
56            continue;
57        };
58
59        for new_col in &new_table.columns {
60            let Some(old_col) = old_table
61                .columns
62                .iter()
63                .find(|old_col| old_col.name == new_col.name)
64            else {
65                continue;
66            };
67
68            if check_signature(&old_col.check) != check_signature(&new_col.check) {
69                changes.push(format!("{}.{}", table_name, new_col.name));
70            }
71        }
72    }
73
74    changes.sort();
75    changes
76}
77
78fn existing_column_foreign_key_diffs(old: &Schema, new: &Schema) -> Vec<String> {
79    let mut changes = Vec::new();
80
81    for (table_name, new_table) in &new.tables {
82        let Some(old_table) = old.tables.get(table_name) else {
83            continue;
84        };
85
86        for new_col in &new_table.columns {
87            let Some(old_col) = old_table
88                .columns
89                .iter()
90                .find(|old_col| old_col.name == new_col.name)
91            else {
92                continue;
93            };
94
95            if foreign_key_signature(&old_col.foreign_key)
96                != foreign_key_signature(&new_col.foreign_key)
97            {
98                changes.push(format!("{}.{}", table_name, new_col.name));
99            }
100        }
101    }
102
103    changes.sort();
104    changes
105}
106
107fn existing_column_unique_diffs(old: &Schema, new: &Schema) -> Vec<String> {
108    let mut changes = Vec::new();
109
110    for (table_name, new_table) in &new.tables {
111        let Some(old_table) = old.tables.get(table_name) else {
112            continue;
113        };
114
115        for new_col in &new_table.columns {
116            let Some(old_col) = old_table
117                .columns
118                .iter()
119                .find(|old_col| old_col.name == new_col.name)
120            else {
121                continue;
122            };
123
124            if old_col.unique != new_col.unique {
125                changes.push(format!("{}.{}", table_name, new_col.name));
126            }
127        }
128    }
129
130    changes.sort();
131    changes
132}
133
134fn existing_column_primary_key_diffs(old: &Schema, new: &Schema) -> Vec<String> {
135    let mut changes = Vec::new();
136
137    for (table_name, new_table) in &new.tables {
138        let Some(old_table) = old.tables.get(table_name) else {
139            continue;
140        };
141
142        for new_col in &new_table.columns {
143            let Some(old_col) = old_table
144                .columns
145                .iter()
146                .find(|old_col| old_col.name == new_col.name)
147            else {
148                continue;
149            };
150
151            if old_col.primary_key != new_col.primary_key {
152                changes.push(format!("{}.{}", table_name, new_col.name));
153            }
154        }
155    }
156
157    changes.sort();
158    changes
159}
160
161fn existing_column_generated_diffs(old: &Schema, new: &Schema) -> Vec<String> {
162    let mut changes = Vec::new();
163
164    for (table_name, new_table) in &new.tables {
165        let Some(old_table) = old.tables.get(table_name) else {
166            continue;
167        };
168
169        for new_col in &new_table.columns {
170            let Some(old_col) = old_table
171                .columns
172                .iter()
173                .find(|old_col| old_col.name == new_col.name)
174            else {
175                continue;
176            };
177
178            if generated_signature(&old_col.generated) != generated_signature(&new_col.generated) {
179                changes.push(format!("{}.{}", table_name, new_col.name));
180            }
181        }
182    }
183
184    changes.sort();
185    changes
186}
187
188fn new_column_primary_key_additions(old: &Schema, new: &Schema) -> Vec<String> {
189    let mut changes = Vec::new();
190
191    for (table_name, new_table) in &new.tables {
192        let Some(old_table) = old.tables.get(table_name) else {
193            continue;
194        };
195
196        for new_col in &new_table.columns {
197            if new_col.primary_key
198                && !old_table
199                    .columns
200                    .iter()
201                    .any(|old_col| old_col.name == new_col.name)
202            {
203                changes.push(format!("{}.{}", table_name, new_col.name));
204            }
205        }
206    }
207
208    changes.sort();
209    changes
210}
211
212fn same_name_index_definition_diffs(old: &Schema, new: &Schema) -> Vec<String> {
213    let mut changes = Vec::new();
214
215    for new_idx in &new.indexes {
216        let Some(old_idx) = old
217            .indexes
218            .iter()
219            .find(|old_idx| old_idx.name == new_idx.name)
220        else {
221            continue;
222        };
223
224        if index_signature(old_idx) != index_signature(new_idx) {
225            changes.push(new_idx.name.clone());
226        }
227    }
228
229    changes.sort();
230    changes.dedup();
231    changes
232}
233
234fn check_signature(check: &Option<super::schema::CheckConstraint>) -> Option<String> {
235    check
236        .as_ref()
237        .map(|check| format!("{:?}:{:?}", check.name, check.expr))
238}
239
240fn foreign_key_signature(fk: &Option<super::schema::ForeignKey>) -> Option<String> {
241    fk.as_ref().map(|fk| format!("{:?}", fk))
242}
243
244fn generated_signature(generated: &Option<Generated>) -> Option<String> {
245    match generated {
246        Some(Generated::AlwaysStored(expr)) => Some(format!("stored:{expr}")),
247        Some(Generated::AlwaysIdentity) => Some("identity:always".to_string()),
248        Some(Generated::ByDefaultIdentity) => Some("identity:by_default".to_string()),
249        None => None,
250    }
251}
252
253fn generated_to_constraint(generated: &Generated) -> Constraint {
254    match generated {
255        Generated::AlwaysStored(expr) => {
256            Constraint::Generated(ColumnGeneration::Stored(expr.clone()))
257        }
258        Generated::AlwaysIdentity => {
259            Constraint::Generated(ColumnGeneration::Stored("identity".to_string()))
260        }
261        Generated::ByDefaultIdentity => {
262            Constraint::Generated(ColumnGeneration::Stored("identity_by_default".to_string()))
263        }
264    }
265}
266
267fn index_signature(idx: &super::schema::Index) -> String {
268    format!(
269        "table={:?};columns={:?};expressions={:?};unique={};method={};where={:?};include={:?};concurrently={}",
270        idx.table,
271        idx.columns,
272        idx.expressions,
273        idx.unique,
274        index_method_str(&idx.method),
275        idx.where_clause.as_ref().map(check_expr_to_sql),
276        idx.include,
277        idx.concurrently
278    )
279}
280
281fn table_references_table(table: &super::schema::Table, target: &str) -> bool {
282    table.columns.iter().any(|col| {
283        col.foreign_key
284            .as_ref()
285            .is_some_and(|fk| fk.table == target)
286    }) || table
287        .multi_column_fks
288        .iter()
289        .any(|fk| fk.ref_table == target)
290}
291
292/// Validate that a schema pair is fully supported by state-based diff.
293///
294/// Returns an error when object families outside table/index/hint coverage are present.
295pub fn validate_state_diff_support(old: &Schema, new: &Schema) -> Result<(), String> {
296    let mut unsupported = unsupported_state_diff_features(old);
297    unsupported.extend(unsupported_state_diff_features(new));
298
299    if !unsupported.is_empty() {
300        let detail = unsupported.into_iter().collect::<Vec<_>>().join(", ");
301        return Err(format!(
302            "State-based diff currently supports tables, columns, indexes, and migration hints only. \
303             Unsupported schema object families present: {}. \
304             Use folder-based strict migrations for these objects.",
305            detail
306        ));
307    }
308
309    let index_diffs = same_name_index_definition_diffs(old, new);
310    if !index_diffs.is_empty() {
311        return Err(format!(
312            "State-based diff cannot safely replace existing indexes with changed definitions: {}. \
313             Use an explicit migration for DROP INDEX/CREATE INDEX replacement.",
314            index_diffs.join(", ")
315        ));
316    }
317
318    let check_diffs = existing_column_check_diffs(old, new);
319    if !check_diffs.is_empty() {
320        return Err(format!(
321            "State-based diff cannot safely alter CHECK constraints on existing columns: {}. \
322             Use an explicit migration for ADD/DROP/replace CHECK constraints.",
323            check_diffs.join(", ")
324        ));
325    }
326
327    let unique_diffs = existing_column_unique_diffs(old, new);
328    if !unique_diffs.is_empty() {
329        return Err(format!(
330            "State-based diff cannot safely alter UNIQUE constraints on existing columns: {}. \
331             Use an explicit migration for ADD/DROP/replace UNIQUE constraints.",
332            unique_diffs.join(", ")
333        ));
334    }
335
336    let pk_diffs = existing_column_primary_key_diffs(old, new);
337    if !pk_diffs.is_empty() {
338        return Err(format!(
339            "State-based diff cannot safely alter PRIMARY KEY constraints on existing columns: {}. \
340             Use an explicit migration for ADD/DROP/replace PRIMARY KEY constraints.",
341            pk_diffs.join(", ")
342        ));
343    }
344
345    let new_pk_columns = new_column_primary_key_additions(old, new);
346    if !new_pk_columns.is_empty() {
347        return Err(format!(
348            "State-based diff cannot safely add PRIMARY KEY columns to existing tables: {}. \
349             Use an explicit migration to backfill data and add the PRIMARY KEY constraint.",
350            new_pk_columns.join(", ")
351        ));
352    }
353
354    let fk_diffs = existing_column_foreign_key_diffs(old, new);
355    if !fk_diffs.is_empty() {
356        return Err(format!(
357            "State-based diff cannot safely alter single-column foreign keys on existing columns: {}. \
358             Use an explicit migration for ADD/DROP/replace FOREIGN KEY constraints.",
359            fk_diffs.join(", ")
360        ));
361    }
362
363    let generated_diffs = existing_column_generated_diffs(old, new);
364    if !generated_diffs.is_empty() {
365        return Err(format!(
366            "State-based diff cannot safely alter GENERATED/IDENTITY clauses on existing columns: {}. \
367             Use an explicit migration for GENERATED/IDENTITY changes.",
368            generated_diffs.join(", ")
369        ));
370    }
371
372    Ok(())
373}
374
375/// Checked variant of [`diff_schemas`] that rejects unsupported object families.
376pub fn diff_schemas_checked(old: &Schema, new: &Schema) -> Result<Vec<Qail>, String> {
377    validate_state_diff_support(old, new)?;
378    Ok(diff_schemas(old, new))
379}
380
381/// Compute the difference between two schemas.
382/// Returns a `Vec<Qail>` representing the operations needed to migrate
383/// from `old` to `new`. Respects MigrationHint for intent-aware diffing.
384pub fn diff_schemas(old: &Schema, new: &Schema) -> Vec<Qail> {
385    let mut cmds = Vec::new();
386
387    // Process migration hints first (intent-aware)
388    for hint in &new.migrations {
389        match hint {
390            MigrationHint::Rename { from, to } => {
391                if let (Some((from_table, from_col)), Some((to_table, to_col))) =
392                    (parse_table_col(from), parse_table_col(to))
393                    && from_table == to_table
394                {
395                    // Same table rename - use ALTER TABLE RENAME COLUMN
396                    cmds.push(Qail {
397                        action: Action::Mod,
398                        table: from_table.to_string(),
399                        columns: vec![Expr::Named(format!("{} -> {}", from_col, to_col))],
400                        ..Default::default()
401                    });
402                }
403            }
404            MigrationHint::Transform { expression, target } => {
405                if let Some((table, _col)) = parse_table_col(target) {
406                    cmds.push(Qail {
407                        action: Action::Set,
408                        table: table.to_string(),
409                        columns: vec![Expr::Named(format!("/* TRANSFORM: {} */", expression))],
410                        ..Default::default()
411                    });
412                }
413            }
414            MigrationHint::Drop {
415                target,
416                confirmed: true,
417            } => {
418                if target.contains('.') {
419                    // Drop column
420                    if let Some((table, col)) = parse_table_col(target) {
421                        cmds.push(Qail {
422                            action: Action::AlterDrop,
423                            table: table.to_string(),
424                            columns: vec![Expr::Named(col.to_string())],
425                            ..Default::default()
426                        });
427                    }
428                } else {
429                    // Drop table
430                    cmds.push(Qail {
431                        action: Action::Drop,
432                        table: target.clone(),
433                        ..Default::default()
434                    });
435                }
436            }
437            _ => {}
438        }
439    }
440
441    // Collect new tables (not in old schema), sorted by FK dependencies
442    let new_table_names: Vec<&String> = new
443        .tables
444        .keys()
445        .filter(|name| !old.tables.contains_key(*name))
446        .collect();
447
448    // Simple FK-aware sort: tables with no FK deps first, then others
449    // This handles the common case of parent -> child relationships
450    // Use iterative topological sort: in each round, emit tables whose FK targets
451    // are either already emitted or not in this batch (pre-existing tables).
452    let new_set: std::collections::HashSet<&str> =
453        new_table_names.iter().map(|n| n.as_str()).collect();
454    let mut emitted: std::collections::HashSet<&str> = std::collections::HashSet::new();
455    let mut sorted: Vec<&String> = Vec::with_capacity(new_table_names.len());
456    let mut remaining = new_table_names;
457
458    loop {
459        let before = sorted.len();
460        remaining.retain(|name| {
461            let deps_satisfied = new.tables.get(*name).is_none_or(|t| {
462                t.columns.iter().all(|c| {
463                    c.foreign_key.as_ref().is_none_or(|fk| {
464                        !new_set.contains(fk.table.as_str()) || emitted.contains(fk.table.as_str())
465                    })
466                }) && t.multi_column_fks.iter().all(|fk| {
467                    !new_set.contains(fk.ref_table.as_str())
468                        || emitted.contains(fk.ref_table.as_str())
469                })
470            });
471            if deps_satisfied {
472                emitted.insert(name.as_str());
473                sorted.push(name);
474                false // remove from remaining
475            } else {
476                true // keep in remaining
477            }
478        });
479        if remaining.is_empty() || sorted.len() == before {
480            // Either done or circular deps — append remaining as-is
481            sorted.extend(remaining);
482            break;
483        }
484    }
485
486    let new_table_names = sorted;
487
488    // Generate CREATE TABLE commands in dependency order
489    for name in new_table_names {
490        let table = &new.tables[name];
491        let columns: Vec<Expr> = table
492            .columns
493            .iter()
494            .map(|col| {
495                let mut constraints = Vec::new();
496                if col.primary_key {
497                    constraints.push(Constraint::PrimaryKey);
498                }
499                if col.nullable {
500                    constraints.push(Constraint::Nullable);
501                }
502                if col.unique {
503                    constraints.push(Constraint::Unique);
504                }
505                if let Some(def) = &col.default {
506                    constraints.push(Constraint::Default(def.clone()));
507                }
508                if let Some(ref fk) = col.foreign_key {
509                    constraints.push(Constraint::References(foreign_key_to_sql(fk)));
510                }
511                if let Some(check) = &col.check {
512                    let check_sql = check_expr_to_sql(&check.expr);
513                    if let Some(name) = &check.name {
514                        constraints.push(Constraint::Check(vec![format!(
515                            "CONSTRAINT {} CHECK ({})",
516                            name, check_sql
517                        )]));
518                    } else {
519                        constraints.push(Constraint::Check(vec![check_sql]));
520                    }
521                }
522                if let Some(generated) = &col.generated {
523                    constraints.push(generated_to_constraint(generated));
524                }
525
526                Expr::Def {
527                    name: col.name.clone(),
528                    data_type: col.data_type.to_pg_type(),
529                    constraints,
530                }
531            })
532            .collect();
533
534        cmds.push(Qail {
535            action: Action::Make,
536            table: name.clone(),
537            columns,
538            ..Default::default()
539        });
540
541        if table.enable_rls {
542            cmds.push(Qail {
543                action: Action::AlterEnableRls,
544                table: name.clone(),
545                ..Default::default()
546            });
547        }
548        if table.force_rls {
549            cmds.push(Qail {
550                action: Action::AlterForceRls,
551                table: name.clone(),
552                ..Default::default()
553            });
554        }
555    }
556
557    // Detect dropped tables (only if not already handled by hints)
558    let mut dropped_tables: Vec<&String> = old
559        .tables
560        .keys()
561        .filter(|name| {
562            !new.tables.contains_key(*name) && !new.migrations.iter().any(
563                |h| matches!(h, MigrationHint::Drop { target, confirmed: true } if target == *name),
564            )
565        })
566        .collect();
567
568    dropped_tables.sort();
569    let mut remaining = dropped_tables;
570    let mut dropped_tables = Vec::with_capacity(remaining.len());
571    while !remaining.is_empty() {
572        let before = dropped_tables.len();
573        let remaining_names: Vec<String> = remaining.iter().map(|name| (*name).clone()).collect();
574        let mut next_remaining = Vec::new();
575
576        for name in remaining {
577            let has_dropped_dependent = remaining_names.iter().any(|other| {
578                other.as_str() != name.as_str()
579                    && old
580                        .tables
581                        .get(other)
582                        .is_some_and(|table| table_references_table(table, name))
583            });
584
585            if has_dropped_dependent {
586                next_remaining.push(name);
587            } else {
588                dropped_tables.push(name);
589            }
590        }
591
592        if dropped_tables.len() == before {
593            next_remaining.sort();
594            dropped_tables.extend(next_remaining);
595            break;
596        }
597
598        remaining = next_remaining;
599    }
600
601    for name in dropped_tables {
602        cmds.push(Qail {
603            action: Action::Drop,
604            table: name.clone(),
605            ..Default::default()
606        });
607    }
608
609    // Detect column changes in existing tables
610    for (name, new_table) in &new.tables {
611        if let Some(old_table) = old.tables.get(name) {
612            let old_cols: std::collections::HashSet<_> =
613                old_table.columns.iter().map(|c| &c.name).collect();
614            let new_cols: std::collections::HashSet<_> =
615                new_table.columns.iter().map(|c| &c.name).collect();
616
617            // New columns
618            for col in &new_table.columns {
619                if !old_cols.contains(&col.name) {
620                    let col_path = format!("{}.{}", name, col.name);
621                    let is_rename_target = new
622                        .migrations
623                        .iter()
624                        .any(|h| matches!(h, MigrationHint::Rename { to, .. } if to == &col_path));
625
626                    if !is_rename_target {
627                        let mut constraints = Vec::new();
628                        if col.nullable {
629                            constraints.push(Constraint::Nullable);
630                        }
631                        if col.unique {
632                            constraints.push(Constraint::Unique);
633                        }
634                        if let Some(def) = &col.default {
635                            constraints.push(Constraint::Default(def.clone()));
636                        }
637                        if let Some(fk) = &col.foreign_key {
638                            constraints.push(Constraint::References(foreign_key_to_sql(fk)));
639                        }
640                        if let Some(check) = &col.check {
641                            let check_sql = check_expr_to_sql(&check.expr);
642                            if let Some(name) = &check.name {
643                                constraints.push(Constraint::Check(vec![format!(
644                                    "CONSTRAINT {} CHECK ({})",
645                                    name, check_sql
646                                )]));
647                            } else {
648                                constraints.push(Constraint::Check(vec![check_sql]));
649                            }
650                        }
651                        if let Some(generated) = &col.generated {
652                            constraints.push(generated_to_constraint(generated));
653                        }
654                        // SERIAL is a pseudo-type only valid in CREATE TABLE
655                        // For ALTER TABLE ADD COLUMN, convert to INTEGER/BIGINT
656                        let data_type = match &col.data_type {
657                            super::types::ColumnType::Serial => "INTEGER".to_string(),
658                            super::types::ColumnType::BigSerial => "BIGINT".to_string(),
659                            other => other.to_pg_type(),
660                        };
661
662                        cmds.push(Qail {
663                            action: Action::Alter,
664                            table: name.clone(),
665                            columns: vec![Expr::Def {
666                                name: col.name.clone(),
667                                data_type,
668                                constraints,
669                            }],
670                            ..Default::default()
671                        });
672                    }
673                }
674            }
675
676            // Dropped columns (not handled by hints)
677            for col in &old_table.columns {
678                if !new_cols.contains(&col.name) {
679                    let col_path = format!("{}.{}", name, col.name);
680                    let is_rename_source = new.migrations.iter().any(
681                        |h| matches!(h, MigrationHint::Rename { from, .. } if from == &col_path),
682                    );
683
684                    let is_drop_hinted = new.migrations.iter().any(|h| {
685                        matches!(h, MigrationHint::Drop { target, confirmed: true } if target == &col_path)
686                    });
687
688                    if !is_rename_source && !is_drop_hinted {
689                        cmds.push(Qail {
690                            action: Action::AlterDrop,
691                            table: name.clone(),
692                            columns: vec![Expr::Named(col.name.clone())],
693                            ..Default::default()
694                        });
695                    }
696                }
697            }
698
699            // Detect type changes in existing columns
700            for new_col in &new_table.columns {
701                if let Some(old_col) = old_table.columns.iter().find(|c| c.name == new_col.name) {
702                    let old_type = old_col.data_type.to_pg_type();
703                    let new_type = new_col.data_type.to_pg_type();
704
705                    if old_type != new_type {
706                        // Type changed - ALTER COLUMN TYPE
707                        // SERIAL is pseudo-type only valid in CREATE TABLE
708                        let safe_new_type = match &new_col.data_type {
709                            super::types::ColumnType::Serial => "INTEGER".to_string(),
710                            super::types::ColumnType::BigSerial => "BIGINT".to_string(),
711                            _ => new_type,
712                        };
713
714                        cmds.push(Qail {
715                            action: Action::AlterType,
716                            table: name.clone(),
717                            columns: vec![Expr::Def {
718                                name: new_col.name.clone(),
719                                data_type: safe_new_type,
720                                constraints: vec![],
721                            }],
722                            ..Default::default()
723                        });
724                    }
725
726                    // Detect NOT NULL changes
727                    if old_col.nullable && !new_col.nullable && !new_col.primary_key {
728                        // Was nullable, now NOT NULL → SET NOT NULL
729                        cmds.push(Qail {
730                            action: Action::AlterSetNotNull,
731                            table: name.clone(),
732                            columns: vec![Expr::Named(new_col.name.clone())],
733                            ..Default::default()
734                        });
735                    } else if !old_col.nullable && new_col.nullable && !old_col.primary_key {
736                        // Was NOT NULL, now nullable → DROP NOT NULL
737                        cmds.push(Qail {
738                            action: Action::AlterDropNotNull,
739                            table: name.clone(),
740                            columns: vec![Expr::Named(new_col.name.clone())],
741                            ..Default::default()
742                        });
743                    }
744
745                    // Detect DEFAULT changes
746                    match (&old_col.default, &new_col.default) {
747                        (None, Some(new_default)) => {
748                            // No default before, now has one → SET DEFAULT
749                            cmds.push(Qail {
750                                action: Action::AlterSetDefault,
751                                table: name.clone(),
752                                columns: vec![Expr::Named(new_col.name.clone())],
753                                payload: Some(new_default.clone()),
754                                ..Default::default()
755                            });
756                        }
757                        (Some(_), None) => {
758                            // Had default, now removed → DROP DEFAULT
759                            cmds.push(Qail {
760                                action: Action::AlterDropDefault,
761                                table: name.clone(),
762                                columns: vec![Expr::Named(new_col.name.clone())],
763                                ..Default::default()
764                            });
765                        }
766                        (Some(old_default), Some(new_default)) if old_default != new_default => {
767                            // Default value changed → SET DEFAULT (new)
768                            cmds.push(Qail {
769                                action: Action::AlterSetDefault,
770                                table: name.clone(),
771                                columns: vec![Expr::Named(new_col.name.clone())],
772                                payload: Some(new_default.clone()),
773                                ..Default::default()
774                            });
775                        }
776                        _ => {} // Same or both None
777                    }
778                }
779            }
780
781            // Detect RLS changes
782            if !old_table.enable_rls && new_table.enable_rls {
783                cmds.push(Qail {
784                    action: Action::AlterEnableRls,
785                    table: name.clone(),
786                    ..Default::default()
787                });
788            } else if old_table.enable_rls && !new_table.enable_rls {
789                cmds.push(Qail {
790                    action: Action::AlterDisableRls,
791                    table: name.clone(),
792                    ..Default::default()
793                });
794            }
795
796            if !old_table.force_rls && new_table.force_rls {
797                cmds.push(Qail {
798                    action: Action::AlterForceRls,
799                    table: name.clone(),
800                    ..Default::default()
801                });
802            } else if old_table.force_rls && !new_table.force_rls {
803                cmds.push(Qail {
804                    action: Action::AlterNoForceRls,
805                    table: name.clone(),
806                    ..Default::default()
807                });
808            }
809        }
810    }
811
812    // Detect new indexes
813    for new_idx in &new.indexes {
814        let exists = old.indexes.iter().any(|i| i.name == new_idx.name);
815        if !exists {
816            cmds.push(Qail {
817                action: Action::Index,
818                table: String::new(),
819                index_def: Some(IndexDef {
820                    name: new_idx.name.clone(),
821                    table: new_idx.table.clone(),
822                    columns: if !new_idx.expressions.is_empty() {
823                        new_idx.expressions.clone()
824                    } else {
825                        new_idx.columns.clone()
826                    },
827                    unique: new_idx.unique,
828                    index_type: Some(index_method_str(&new_idx.method).to_string()),
829                    where_clause: new_idx.where_clause.as_ref().map(check_expr_to_sql),
830                }),
831                ..Default::default()
832            });
833        }
834    }
835
836    let mut fk_table_names: Vec<&String> = new
837        .tables
838        .iter()
839        .filter(|(_, table)| !table.multi_column_fks.is_empty())
840        .map(|(name, _)| name)
841        .collect();
842    fk_table_names.sort();
843    for name in fk_table_names {
844        let new_table = &new.tables[name];
845        if let Some(old_table) = old.tables.get(name) {
846            for fk in &new_table.multi_column_fks {
847                if !old_table.multi_column_fks.contains(fk) {
848                    cmds.push(multi_column_fk_to_alter_command(name, fk));
849                }
850            }
851        } else {
852            for fk in &new_table.multi_column_fks {
853                cmds.push(multi_column_fk_to_alter_command(name, fk));
854            }
855        }
856    }
857
858    // Detect dropped indexes
859    for old_idx in &old.indexes {
860        let exists = new.indexes.iter().any(|i| i.name == old_idx.name);
861        if !exists {
862            cmds.push(Qail {
863                action: Action::DropIndex,
864                table: old_idx.name.clone(),
865                ..Default::default()
866            });
867        }
868    }
869
870    cmds
871}
872
873/// Parse "table.column" format
874fn parse_table_col(s: &str) -> Option<(&str, &str)> {
875    let parts: Vec<&str> = s.splitn(2, '.').collect();
876    if parts.len() == 2 {
877        Some((parts[0], parts[1]))
878    } else {
879        None
880    }
881}
882
883#[cfg(test)]
884mod tests {
885    use super::super::schema::{
886        CheckExpr, Column, FkAction, Index, IndexMethod, MultiColumnForeignKey, Table, ViewDef,
887    };
888    use super::*;
889
890    #[test]
891    fn test_diff_new_table() {
892        use super::super::types::ColumnType;
893        let old = Schema::default();
894        let mut new = Schema::default();
895        new.add_table(
896            Table::new("users")
897                .column(Column::new("id", ColumnType::Serial).primary_key())
898                .column(Column::new("name", ColumnType::Text).not_null()),
899        );
900
901        let cmds = diff_schemas(&old, &new);
902        assert_eq!(cmds.len(), 1);
903        assert!(matches!(cmds[0].action, Action::Make));
904    }
905
906    #[test]
907    fn state_diff_support_rejects_non_table_object_families() {
908        let old = Schema::default();
909        let mut new = Schema::default();
910        new.add_view(ViewDef::new("active_users", "SELECT 1"));
911
912        let err = validate_state_diff_support(&old, &new)
913            .expect_err("state-based diff should reject unsupported view objects");
914        assert!(
915            err.contains("views"),
916            "error should include unsupported family name"
917        );
918    }
919
920    #[test]
921    fn state_diff_checked_passes_for_table_index_only_schema() {
922        use super::super::types::ColumnType;
923        let old = Schema::default();
924        let mut new = Schema::default();
925        new.add_table(Table::new("users").column(Column::new("id", ColumnType::Serial)));
926        let cmds = diff_schemas_checked(&old, &new).expect("table/index-only schema should pass");
927        assert!(
928            cmds.iter().any(|c| matches!(c.action, Action::Make)),
929            "checked diff should still produce normal table commands"
930        );
931    }
932
933    fn schema_with_users_index(index: Index) -> Schema {
934        use super::super::types::ColumnType;
935
936        let mut schema = Schema::default();
937        schema.add_table(
938            Table::new("users")
939                .column(Column::new("email", ColumnType::Text))
940                .column(Column::new("username", ColumnType::Text))
941                .column(Column::new("deleted_at", ColumnType::Text)),
942        );
943        schema.add_index(index);
944        schema
945    }
946
947    #[test]
948    fn state_diff_checked_rejects_same_name_index_unique_change() {
949        let old = schema_with_users_index(Index::new(
950            "idx_users_email",
951            "users",
952            vec!["email".to_string()],
953        ));
954        let new = schema_with_users_index(
955            Index::new("idx_users_email", "users", vec!["email".to_string()]).unique(),
956        );
957
958        let err = diff_schemas_checked(&old, &new)
959            .expect_err("same-name index unique change should fail closed");
960        assert!(err.contains("replace existing indexes"));
961        assert!(err.contains("idx_users_email"));
962    }
963
964    #[test]
965    fn state_diff_checked_rejects_same_name_index_predicate_change() {
966        let old = schema_with_users_index(
967            Index::new("idx_users_email", "users", vec!["email".to_string()])
968                .partial(CheckExpr::Sql("deleted_at IS NULL".to_string())),
969        );
970        let new = schema_with_users_index(
971            Index::new("idx_users_email", "users", vec!["email".to_string()])
972                .partial(CheckExpr::Sql("deleted_at IS NOT NULL".to_string())),
973        );
974
975        let err = diff_schemas_checked(&old, &new)
976            .expect_err("same-name index predicate change should fail closed");
977        assert!(err.contains("replace existing indexes"));
978        assert!(err.contains("idx_users_email"));
979    }
980
981    #[test]
982    fn state_diff_checked_rejects_same_name_index_method_change() {
983        let old = schema_with_users_index(Index::new(
984            "idx_users_email",
985            "users",
986            vec!["email".to_string()],
987        ));
988        let new = schema_with_users_index(
989            Index::new("idx_users_email", "users", vec!["email".to_string()])
990                .using(IndexMethod::Hash),
991        );
992
993        let err = diff_schemas_checked(&old, &new)
994            .expect_err("same-name index method change should fail closed");
995        assert!(err.contains("replace existing indexes"));
996        assert!(err.contains("idx_users_email"));
997    }
998
999    #[test]
1000    fn state_diff_checked_rejects_same_name_index_column_change() {
1001        let old = schema_with_users_index(Index::new(
1002            "idx_users_email",
1003            "users",
1004            vec!["email".to_string()],
1005        ));
1006        let new = schema_with_users_index(Index::new(
1007            "idx_users_email",
1008            "users",
1009            vec!["username".to_string()],
1010        ));
1011
1012        let err = diff_schemas_checked(&old, &new)
1013            .expect_err("same-name index column change should fail closed");
1014        assert!(err.contains("replace existing indexes"));
1015        assert!(err.contains("idx_users_email"));
1016    }
1017
1018    #[test]
1019    fn state_diff_checked_rejects_existing_column_check_addition() {
1020        use super::super::types::ColumnType;
1021
1022        let mut old = Schema::default();
1023        old.add_table(
1024            Table::new("inventory").column(Column::new("quantity", ColumnType::Int).not_null()),
1025        );
1026
1027        let mut new = Schema::default();
1028        new.add_table(
1029            Table::new("inventory").column(
1030                Column::new("quantity", ColumnType::Int).not_null().check(
1031                    CheckExpr::GreaterOrEqual {
1032                        column: "quantity".to_string(),
1033                        value: 0,
1034                    },
1035                ),
1036            ),
1037        );
1038
1039        let err = diff_schemas_checked(&old, &new)
1040            .expect_err("existing-column CHECK change should fail closed");
1041        assert!(err.contains("CHECK constraints"));
1042        assert!(err.contains("inventory.quantity"));
1043    }
1044
1045    #[test]
1046    fn state_diff_checked_rejects_existing_column_unique_addition() {
1047        use super::super::types::ColumnType;
1048
1049        let mut old = Schema::default();
1050        old.add_table(
1051            Table::new("users").column(Column::new("email", ColumnType::Text).not_null()),
1052        );
1053
1054        let mut new = Schema::default();
1055        new.add_table(
1056            Table::new("users").column(Column::new("email", ColumnType::Text).not_null().unique()),
1057        );
1058
1059        let err = diff_schemas_checked(&old, &new)
1060            .expect_err("existing-column UNIQUE change should fail closed");
1061        assert!(err.contains("UNIQUE constraints"));
1062        assert!(err.contains("users.email"));
1063    }
1064
1065    #[test]
1066    fn state_diff_checked_rejects_existing_column_primary_key_addition() {
1067        use super::super::types::ColumnType;
1068
1069        let mut old = Schema::default();
1070        old.add_table(Table::new("api_keys").column(Column::new("key", ColumnType::Text)));
1071
1072        let mut new = Schema::default();
1073        new.add_table(
1074            Table::new("api_keys").column(Column::new("key", ColumnType::Text).primary_key()),
1075        );
1076
1077        let err = diff_schemas_checked(&old, &new)
1078            .expect_err("existing-column PRIMARY KEY addition should fail closed");
1079        assert!(err.contains("PRIMARY KEY constraints"));
1080        assert!(err.contains("api_keys.key"));
1081    }
1082
1083    #[test]
1084    fn state_diff_checked_rejects_existing_column_primary_key_removal() {
1085        use super::super::types::ColumnType;
1086
1087        let mut old = Schema::default();
1088        old.add_table(
1089            Table::new("api_keys").column(Column::new("key", ColumnType::Text).primary_key()),
1090        );
1091
1092        let mut new = Schema::default();
1093        new.add_table(Table::new("api_keys").column(Column::new("key", ColumnType::Text)));
1094
1095        let err = diff_schemas_checked(&old, &new)
1096            .expect_err("existing-column PRIMARY KEY removal should fail closed");
1097        assert!(err.contains("PRIMARY KEY constraints"));
1098        assert!(err.contains("api_keys.key"));
1099    }
1100
1101    #[test]
1102    fn state_diff_checked_rejects_new_primary_key_column_on_existing_table() {
1103        use super::super::types::ColumnType;
1104
1105        let mut old = Schema::default();
1106        old.add_table(Table::new("api_keys").column(Column::new("label", ColumnType::Text)));
1107
1108        let mut new = old.clone();
1109        new.tables
1110            .get_mut("api_keys")
1111            .expect("api_keys table should exist")
1112            .columns
1113            .push(Column::new("key", ColumnType::Text).primary_key());
1114
1115        let err = diff_schemas_checked(&old, &new)
1116            .expect_err("new PRIMARY KEY column on existing table should fail closed");
1117        assert!(err.contains("add PRIMARY KEY columns"));
1118        assert!(err.contains("api_keys.key"));
1119    }
1120
1121    #[test]
1122    fn state_diff_checked_rejects_existing_column_foreign_key_addition() {
1123        use super::super::types::ColumnType;
1124
1125        let mut old = Schema::default();
1126        old.add_table(Table::new("tenants").column(Column::new("id", ColumnType::Int)));
1127        old.add_table(Table::new("orders").column(Column::new("tenant_id", ColumnType::Int)));
1128
1129        let mut new = Schema::default();
1130        new.add_table(Table::new("tenants").column(Column::new("id", ColumnType::Int)));
1131        new.add_table(
1132            Table::new("orders")
1133                .column(Column::new("tenant_id", ColumnType::Int).references("tenants", "id")),
1134        );
1135
1136        let err = diff_schemas_checked(&old, &new)
1137            .expect_err("existing-column single-column FK change should fail closed");
1138        assert!(err.contains("single-column foreign keys"));
1139        assert!(err.contains("orders.tenant_id"));
1140    }
1141
1142    #[test]
1143    fn diff_new_column_preserves_foreign_key_reference() {
1144        use super::super::types::ColumnType;
1145        use crate::transpiler::ToSql;
1146
1147        let mut old = Schema::default();
1148        old.add_table(Table::new("tenants").column(Column::new("id", ColumnType::Int)));
1149        old.add_table(Table::new("orders").column(Column::new("id", ColumnType::Int)));
1150
1151        let mut new = old.clone();
1152        new.tables
1153            .get_mut("orders")
1154            .expect("orders table should exist")
1155            .columns
1156            .push(
1157                Column::new("tenant_id", ColumnType::Int)
1158                    .references("tenants", "id")
1159                    .on_delete(FkAction::Cascade)
1160                    .on_update(FkAction::Restrict)
1161                    .initially_deferred(),
1162            );
1163
1164        let cmds = diff_schemas_checked(&old, &new).expect("new referenced column should diff");
1165        let add_col = cmds
1166            .iter()
1167            .find(|cmd| matches!(cmd.action, Action::Alter) && cmd.table == "orders")
1168            .expect("add-column command should be present");
1169
1170        let Expr::Def { constraints, .. } = &add_col.columns[0] else {
1171            panic!("expected added column def");
1172        };
1173        assert!(constraints.iter().any(|constraint| {
1174            matches!(
1175                constraint,
1176                Constraint::References(target)
1177                    if target == "tenants(id) ON DELETE CASCADE ON UPDATE RESTRICT DEFERRABLE INITIALLY DEFERRED"
1178            )
1179        }));
1180
1181        let sql = add_col.to_sql();
1182        assert!(
1183            sql.contains(
1184                "REFERENCES tenants(id) ON DELETE CASCADE ON UPDATE RESTRICT DEFERRABLE INITIALLY DEFERRED"
1185            ),
1186            "add-column SQL should preserve FK reference, got: {sql}"
1187        );
1188    }
1189
1190    #[test]
1191    fn diff_new_column_preserves_check_constraint() {
1192        use super::super::types::ColumnType;
1193        use crate::transpiler::ToSql;
1194
1195        let mut old = Schema::default();
1196        old.add_table(Table::new("players").column(Column::new("id", ColumnType::Int)));
1197
1198        let mut new = old.clone();
1199        new.tables
1200            .get_mut("players")
1201            .expect("players table should exist")
1202            .columns
1203            .push(
1204                Column::new("score", ColumnType::Int).check(CheckExpr::GreaterOrEqual {
1205                    column: "score".to_string(),
1206                    value: 0,
1207                }),
1208            );
1209
1210        let cmds = diff_schemas_checked(&old, &new).expect("new checked column should diff");
1211        let add_col = cmds
1212            .iter()
1213            .find(|cmd| matches!(cmd.action, Action::Alter) && cmd.table == "players")
1214            .expect("add-column command should be present");
1215
1216        let Expr::Def { constraints, .. } = &add_col.columns[0] else {
1217            panic!("expected score column definition");
1218        };
1219        assert!(constraints.iter().any(|constraint| {
1220            matches!(
1221                constraint,
1222                Constraint::Check(vals) if vals.len() == 1 && vals[0] == "score >= 0"
1223            )
1224        }));
1225
1226        let sql = add_col.to_sql();
1227        assert!(
1228            sql.contains("CHECK (score >= 0)"),
1229            "add-column SQL should preserve CHECK constraint, got: {sql}"
1230        );
1231    }
1232
1233    #[test]
1234    fn diff_new_column_preserves_unique_constraint() {
1235        use super::super::types::ColumnType;
1236        use crate::transpiler::ToSql;
1237
1238        let mut old = Schema::default();
1239        old.add_table(Table::new("users").column(Column::new("id", ColumnType::Int)));
1240
1241        let mut new = old.clone();
1242        new.tables
1243            .get_mut("users")
1244            .expect("users table should exist")
1245            .columns
1246            .push(Column::new("email", ColumnType::Text).unique());
1247
1248        let cmds = diff_schemas_checked(&old, &new).expect("new unique column should diff");
1249        let add_col = cmds
1250            .iter()
1251            .find(|cmd| matches!(cmd.action, Action::Alter) && cmd.table == "users")
1252            .expect("add-column command should be present");
1253
1254        let Expr::Def { constraints, .. } = &add_col.columns[0] else {
1255            panic!("expected email column definition");
1256        };
1257        assert!(constraints.contains(&Constraint::Unique));
1258
1259        let sql = add_col.to_sql();
1260        assert!(
1261            sql.contains("UNIQUE"),
1262            "add-column SQL should preserve UNIQUE constraint, got: {sql}"
1263        );
1264    }
1265
1266    #[test]
1267    fn diff_new_column_preserves_generated_constraint() {
1268        use super::super::types::ColumnType;
1269        use crate::transpiler::ToSql;
1270
1271        let mut old = Schema::default();
1272        old.add_table(
1273            Table::new("people")
1274                .column(Column::new("first_name", ColumnType::Text))
1275                .column(Column::new("last_name", ColumnType::Text)),
1276        );
1277
1278        let mut new = old.clone();
1279        new.tables
1280            .get_mut("people")
1281            .expect("people table should exist")
1282            .columns
1283            .push(
1284                Column::new("full_name", ColumnType::Text)
1285                    .generated_stored("first_name || ' ' || last_name"),
1286            );
1287
1288        let cmds = diff_schemas_checked(&old, &new).expect("new generated column should diff");
1289        let add_col = cmds
1290            .iter()
1291            .find(|cmd| matches!(cmd.action, Action::Alter) && cmd.table == "people")
1292            .expect("add-column command should be present");
1293
1294        let Expr::Def { constraints, .. } = &add_col.columns[0] else {
1295            panic!("expected generated column definition");
1296        };
1297        assert!(constraints.iter().any(|constraint| {
1298            matches!(
1299                constraint,
1300                Constraint::Generated(ColumnGeneration::Stored(expr))
1301                    if expr == "first_name || ' ' || last_name"
1302            )
1303        }));
1304
1305        let sql = add_col.to_sql();
1306        assert!(
1307            sql.contains("GENERATED ALWAYS AS (first_name || ' ' || last_name) STORED"),
1308            "add-column SQL should preserve GENERATED clause, got: {sql}"
1309        );
1310    }
1311
1312    #[test]
1313    fn diff_new_table_preserves_foreign_key_actions() {
1314        use super::super::types::ColumnType;
1315        use crate::transpiler::ToSql;
1316
1317        let old = Schema::default();
1318        let mut new = Schema::default();
1319        new.add_table(Table::new("tenants").column(Column::new("id", ColumnType::Int)));
1320        new.add_table(
1321            Table::new("orders").column(
1322                Column::new("tenant_id", ColumnType::Int)
1323                    .references("tenants", "id")
1324                    .on_delete(FkAction::Cascade)
1325                    .on_update(FkAction::Restrict),
1326            ),
1327        );
1328
1329        let cmds = diff_schemas_checked(&old, &new).expect("new table with FK should diff");
1330        let make_cmd = cmds
1331            .iter()
1332            .find(|cmd| matches!(cmd.action, Action::Make) && cmd.table == "orders")
1333            .expect("orders create-table command should be present");
1334
1335        let Expr::Def { constraints, .. } = &make_cmd.columns[0] else {
1336            panic!("expected tenant_id column definition");
1337        };
1338        assert!(constraints.iter().any(|constraint| {
1339            matches!(
1340                constraint,
1341                Constraint::References(target)
1342                    if target == "tenants(id) ON DELETE CASCADE ON UPDATE RESTRICT"
1343            )
1344        }));
1345
1346        let sql = make_cmd.to_sql();
1347        assert!(
1348            sql.contains("REFERENCES tenants(id) ON DELETE CASCADE ON UPDATE RESTRICT"),
1349            "create-table SQL should preserve FK action clauses, got: {sql}"
1350        );
1351    }
1352
1353    #[test]
1354    fn diff_new_table_preserves_generated_and_identity_columns() {
1355        use super::super::types::ColumnType;
1356        use crate::transpiler::ToSql;
1357
1358        let old = Schema::default();
1359        let mut new = Schema::default();
1360        new.add_table(
1361            Table::new("people")
1362                .column(Column::new("first_name", ColumnType::Text))
1363                .column(Column::new("last_name", ColumnType::Text))
1364                .column(
1365                    Column::new("full_name", ColumnType::Text)
1366                        .generated_stored("first_name || ' ' || last_name"),
1367                )
1368                .column(Column::new("row_seq", ColumnType::BigInt).generated_by_default()),
1369        );
1370
1371        let cmds = diff_schemas_checked(&old, &new).expect("new table should diff");
1372        let make_cmd = cmds
1373            .iter()
1374            .find(|cmd| matches!(cmd.action, Action::Make) && cmd.table == "people")
1375            .expect("create-table command should be present");
1376
1377        let sql = make_cmd.to_sql();
1378        assert!(
1379            sql.contains("GENERATED ALWAYS AS (first_name || ' ' || last_name) STORED"),
1380            "create-table SQL should preserve GENERATED clause, got: {sql}"
1381        );
1382        assert!(
1383            sql.contains("GENERATED BY DEFAULT AS IDENTITY"),
1384            "create-table SQL should preserve IDENTITY clause, got: {sql}"
1385        );
1386    }
1387
1388    #[test]
1389    fn state_diff_rejects_generated_changes_on_existing_columns() {
1390        use super::super::types::ColumnType;
1391
1392        let mut old = Schema::default();
1393        old.add_table(Table::new("people").column(Column::new("full_name", ColumnType::Text)));
1394
1395        let mut new = Schema::default();
1396        new.add_table(
1397            Table::new("people").column(
1398                Column::new("full_name", ColumnType::Text)
1399                    .generated_stored("first_name || ' ' || last_name"),
1400            ),
1401        );
1402
1403        let err = validate_state_diff_support(&old, &new)
1404            .expect_err("generated changes on existing columns should fail closed");
1405        assert!(err.contains("GENERATED/IDENTITY"), "{err}");
1406        assert!(err.contains("people.full_name"), "{err}");
1407    }
1408
1409    #[test]
1410    fn diff_new_table_emits_rls_commands_after_create() {
1411        use super::super::types::ColumnType;
1412
1413        let old = Schema::default();
1414        let mut new = Schema::default();
1415        let mut docs = Table::new("docs").column(Column::new("id", ColumnType::Int));
1416        docs.enable_rls = true;
1417        docs.force_rls = true;
1418        new.add_table(docs);
1419
1420        let cmds = diff_schemas_checked(&old, &new).expect("new RLS table should diff");
1421        let make_idx = cmds
1422            .iter()
1423            .position(|cmd| matches!(cmd.action, Action::Make) && cmd.table == "docs")
1424            .expect("create-table command should be present");
1425        let enable_idx = cmds
1426            .iter()
1427            .position(|cmd| matches!(cmd.action, Action::AlterEnableRls) && cmd.table == "docs")
1428            .expect("enable RLS command should be present");
1429        let force_idx = cmds
1430            .iter()
1431            .position(|cmd| matches!(cmd.action, Action::AlterForceRls) && cmd.table == "docs")
1432            .expect("force RLS command should be present");
1433
1434        assert!(make_idx < enable_idx);
1435        assert!(enable_idx < force_idx);
1436    }
1437
1438    #[test]
1439    fn diff_dropped_tables_orders_child_before_parent_by_incoming_fk_topology() {
1440        use super::super::types::ColumnType;
1441
1442        let mut old = Schema::default();
1443        old.add_table(Table::new("root_a").column(Column::new("id", ColumnType::Int)));
1444        old.add_table(Table::new("root_b").column(Column::new("id", ColumnType::Int)));
1445        old.add_table(
1446            Table::new("parent")
1447                .column(Column::new("id", ColumnType::Int))
1448                .column(Column::new("root_a_id", ColumnType::Int).references("root_a", "id"))
1449                .column(Column::new("root_b_id", ColumnType::Int).references("root_b", "id")),
1450        );
1451        old.add_table(
1452            Table::new("child")
1453                .column(Column::new("id", ColumnType::Int))
1454                .column(Column::new("parent_id", ColumnType::Int).references("parent", "id")),
1455        );
1456
1457        let mut new = Schema::default();
1458        new.add_table(Table::new("root_a").column(Column::new("id", ColumnType::Int)));
1459        new.add_table(Table::new("root_b").column(Column::new("id", ColumnType::Int)));
1460
1461        let cmds = diff_schemas_checked(&old, &new).expect("dropped tables should diff");
1462        let child_drop_idx = cmds
1463            .iter()
1464            .position(|cmd| matches!(cmd.action, Action::Drop) && cmd.table == "child")
1465            .expect("child drop should be present");
1466        let parent_drop_idx = cmds
1467            .iter()
1468            .position(|cmd| matches!(cmd.action, Action::Drop) && cmd.table == "parent")
1469            .expect("parent drop should be present");
1470
1471        assert!(
1472            child_drop_idx < parent_drop_idx,
1473            "child table must be dropped before referenced parent table"
1474        );
1475    }
1476
1477    #[test]
1478    fn diff_new_table_preserves_column_check_constraint() {
1479        use super::super::types::ColumnType;
1480        use crate::transpiler::ToSql;
1481
1482        let old = Schema::default();
1483        let mut new = Schema::default();
1484        new.add_table(
1485            Table::new("inventory").column(
1486                Column::new("quantity", ColumnType::Int).not_null().check(
1487                    CheckExpr::GreaterOrEqual {
1488                        column: "quantity".to_string(),
1489                        value: 0,
1490                    },
1491                ),
1492            ),
1493        );
1494
1495        let cmds =
1496            diff_schemas_checked(&old, &new).expect("new table with checked column should diff");
1497        let make_cmd = cmds
1498            .iter()
1499            .find(|cmd| matches!(cmd.action, Action::Make) && cmd.table == "inventory")
1500            .expect("create-table command should be present");
1501
1502        let Expr::Def { constraints, .. } = &make_cmd.columns[0] else {
1503            panic!("expected quantity column definition");
1504        };
1505        assert!(constraints.iter().any(|constraint| {
1506            matches!(
1507                constraint,
1508                Constraint::Check(vals) if vals.len() == 1 && vals[0] == "quantity >= 0"
1509            )
1510        }));
1511
1512        let sql = make_cmd.to_sql();
1513        assert!(
1514            sql.contains("CHECK (quantity >= 0)"),
1515            "create-table SQL should preserve CHECK constraint, got: {sql}"
1516        );
1517    }
1518
1519    #[test]
1520    fn diff_new_partial_unique_index_preserves_predicate() {
1521        use super::super::types::ColumnType;
1522        use crate::transpiler::ToSql;
1523
1524        let mut old = Schema::default();
1525        old.add_table(
1526            Table::new("users")
1527                .column(Column::new("email", ColumnType::Text))
1528                .column(Column::new("deleted_at", ColumnType::Text)),
1529        );
1530
1531        let mut new = old.clone();
1532        new.add_index(
1533            Index::new("idx_users_email_active", "users", vec!["email".to_string()])
1534                .unique()
1535                .partial(CheckExpr::Sql("deleted_at IS NULL".to_string())),
1536        );
1537
1538        let cmds = diff_schemas_checked(&old, &new).expect("new partial index should diff");
1539        let index_cmd = cmds
1540            .iter()
1541            .find(|cmd| matches!(cmd.action, Action::Index))
1542            .expect("index command should be present");
1543        let index_def = index_cmd
1544            .index_def
1545            .as_ref()
1546            .expect("index command should carry index definition");
1547
1548        assert!(index_def.unique);
1549        assert_eq!(index_def.index_type.as_deref(), Some("btree"));
1550        assert_eq!(
1551            index_def.where_clause.as_deref(),
1552            Some("deleted_at IS NULL")
1553        );
1554
1555        let sql = index_cmd.to_sql();
1556        assert!(
1557            sql.contains("WHERE deleted_at IS NULL"),
1558            "index SQL should preserve partial predicate, got: {sql}"
1559        );
1560    }
1561
1562    #[test]
1563    fn test_diff_rename_with_hint() {
1564        use super::super::types::ColumnType;
1565        let mut old = Schema::default();
1566        old.add_table(Table::new("users").column(Column::new("username", ColumnType::Text)));
1567
1568        let mut new = Schema::default();
1569        new.add_table(Table::new("users").column(Column::new("name", ColumnType::Text)));
1570        new.add_hint(MigrationHint::Rename {
1571            from: "users.username".into(),
1572            to: "users.name".into(),
1573        });
1574
1575        let cmds = diff_schemas(&old, &new);
1576        // Should have rename, NOT drop + add
1577        assert!(cmds.iter().any(|c| matches!(c.action, Action::Mod)));
1578        assert!(!cmds.iter().any(|c| matches!(c.action, Action::AlterDrop)));
1579    }
1580
1581    #[test]
1582    fn rename_hint_does_not_suppress_same_named_add_column_in_other_table() {
1583        use super::super::types::ColumnType;
1584
1585        let mut old = Schema::default();
1586        old.add_table(Table::new("users").column(Column::new("username", ColumnType::Text)));
1587        old.add_table(Table::new("profiles").column(Column::new("id", ColumnType::Int)));
1588
1589        let mut new = Schema::default();
1590        new.add_table(Table::new("users").column(Column::new("name", ColumnType::Text)));
1591        new.add_table(
1592            Table::new("profiles")
1593                .column(Column::new("id", ColumnType::Int))
1594                .column(Column::new("name", ColumnType::Text)),
1595        );
1596        new.add_hint(MigrationHint::Rename {
1597            from: "users.username".into(),
1598            to: "users.name".into(),
1599        });
1600
1601        let cmds = diff_schemas_checked(&old, &new).expect("schema should diff");
1602
1603        assert!(cmds.iter().any(|cmd| {
1604            matches!(cmd.action, Action::Alter)
1605                && cmd.table == "profiles"
1606                && matches!(
1607                    cmd.columns.first(),
1608                    Some(Expr::Def { name, .. }) if name == "name"
1609                )
1610        }));
1611    }
1612
1613    #[test]
1614    fn rename_hint_does_not_suppress_same_named_drop_column_in_other_table() {
1615        use super::super::types::ColumnType;
1616
1617        let mut old = Schema::default();
1618        old.add_table(Table::new("users").column(Column::new("username", ColumnType::Text)));
1619        old.add_table(
1620            Table::new("profiles")
1621                .column(Column::new("id", ColumnType::Int))
1622                .column(Column::new("username", ColumnType::Text)),
1623        );
1624
1625        let mut new = Schema::default();
1626        new.add_table(Table::new("users").column(Column::new("name", ColumnType::Text)));
1627        new.add_table(Table::new("profiles").column(Column::new("id", ColumnType::Int)));
1628        new.add_hint(MigrationHint::Rename {
1629            from: "users.username".into(),
1630            to: "users.name".into(),
1631        });
1632
1633        let cmds = diff_schemas_checked(&old, &new).expect("schema should diff");
1634
1635        assert!(cmds.iter().any(|cmd| {
1636            matches!(cmd.action, Action::AlterDrop)
1637                && cmd.table == "profiles"
1638                && matches!(
1639                    cmd.columns.first(),
1640                    Some(Expr::Named(name)) if name == "username"
1641                )
1642        }));
1643    }
1644
1645    /// Regression test: FK parent tables must be created before child tables
1646    #[test]
1647    fn test_fk_ordering_parent_before_child() {
1648        use super::super::types::ColumnType;
1649
1650        let old = Schema::default();
1651
1652        let mut new = Schema::default();
1653        // Child table with FK to parent
1654        new.add_table(
1655            Table::new("child")
1656                .column(Column::new("id", ColumnType::Serial).primary_key())
1657                .column(Column::new("parent_id", ColumnType::Int).references("parent", "id")),
1658        );
1659        // Parent table (no FK)
1660        new.add_table(
1661            Table::new("parent")
1662                .column(Column::new("id", ColumnType::Serial).primary_key())
1663                .column(Column::new("name", ColumnType::Text)),
1664        );
1665
1666        let cmds = diff_schemas(&old, &new);
1667
1668        // Should have 2 CREATE TABLE commands
1669        let make_cmds: Vec<_> = cmds
1670            .iter()
1671            .filter(|c| matches!(c.action, Action::Make))
1672            .collect();
1673        assert_eq!(make_cmds.len(), 2);
1674
1675        // Parent (0 FKs) should come BEFORE child (1 FK)
1676        let parent_idx = make_cmds.iter().position(|c| c.table == "parent").unwrap();
1677        let child_idx = make_cmds.iter().position(|c| c.table == "child").unwrap();
1678        assert!(
1679            parent_idx < child_idx,
1680            "parent table should be created before child with FK"
1681        );
1682    }
1683
1684    /// Regression test: Multiple FK dependencies should be sorted correctly
1685    #[test]
1686    fn test_fk_ordering_multiple_dependencies() {
1687        use super::super::types::ColumnType;
1688
1689        let old = Schema::default();
1690
1691        let mut new = Schema::default();
1692        // Table with 2 FKs (should be last)
1693        new.add_table(
1694            Table::new("order_items")
1695                .column(Column::new("id", ColumnType::Serial).primary_key())
1696                .column(Column::new("order_id", ColumnType::Int).references("orders", "id"))
1697                .column(Column::new("product_id", ColumnType::Int).references("products", "id")),
1698        );
1699        // Table with 1 FK (should be middle)
1700        new.add_table(
1701            Table::new("orders")
1702                .column(Column::new("id", ColumnType::Serial).primary_key())
1703                .column(Column::new("user_id", ColumnType::Int).references("users", "id")),
1704        );
1705        // Table with 0 FKs (should be first)
1706        new.add_table(
1707            Table::new("users").column(Column::new("id", ColumnType::Serial).primary_key()),
1708        );
1709        new.add_table(
1710            Table::new("products").column(Column::new("id", ColumnType::Serial).primary_key()),
1711        );
1712
1713        let cmds = diff_schemas(&old, &new);
1714
1715        let make_cmds: Vec<_> = cmds
1716            .iter()
1717            .filter(|c| matches!(c.action, Action::Make))
1718            .collect();
1719        assert_eq!(make_cmds.len(), 4);
1720
1721        // Get positions
1722        let users_idx = make_cmds.iter().position(|c| c.table == "users").unwrap();
1723        let products_idx = make_cmds
1724            .iter()
1725            .position(|c| c.table == "products")
1726            .unwrap();
1727        let orders_idx = make_cmds.iter().position(|c| c.table == "orders").unwrap();
1728        let items_idx = make_cmds
1729            .iter()
1730            .position(|c| c.table == "order_items")
1731            .unwrap();
1732
1733        // Tables with 0 FKs should come first
1734        assert!(users_idx < orders_idx, "users (0 FK) before orders (1 FK)");
1735        assert!(
1736            products_idx < items_idx,
1737            "products (0 FK) before order_items (2 FK)"
1738        );
1739
1740        // orders (1 FK) should come before order_items (2 FKs)
1741        assert!(
1742            orders_idx < items_idx,
1743            "orders (1 FK) before order_items (2 FK)"
1744        );
1745    }
1746
1747    #[test]
1748    fn diff_new_table_preserves_multi_column_foreign_key() {
1749        use super::super::types::ColumnType;
1750        use crate::transpiler::ToSql;
1751
1752        let old = Schema::default();
1753
1754        let mut new = Schema::default();
1755        new.add_table(
1756            Table::new("schedules")
1757                .column(Column::new("route_id", ColumnType::Text))
1758                .column(Column::new("schedule_id", ColumnType::Text)),
1759        );
1760        new.add_index(
1761            Index::new(
1762                "idx_schedules_route_schedule",
1763                "schedules",
1764                vec!["route_id".to_string(), "schedule_id".to_string()],
1765            )
1766            .unique(),
1767        );
1768        new.add_table(
1769            Table::new("trips")
1770                .column(Column::new("route_id", ColumnType::Text))
1771                .column(Column::new("schedule_id", ColumnType::Text))
1772                .foreign_key(MultiColumnForeignKey::new(
1773                    vec!["route_id".to_string(), "schedule_id".to_string()],
1774                    "schedules",
1775                    vec!["route_id".to_string(), "schedule_id".to_string()],
1776                )),
1777        );
1778
1779        let cmds = diff_schemas(&old, &new);
1780        let schedules_idx = cmds
1781            .iter()
1782            .position(|c| matches!(c.action, Action::Make) && c.table == "schedules")
1783            .expect("schedules create command should exist");
1784        let trips_idx = cmds
1785            .iter()
1786            .position(|c| matches!(c.action, Action::Make) && c.table == "trips")
1787            .expect("trips create command should exist");
1788        let unique_idx = cmds
1789            .iter()
1790            .position(|c| {
1791                matches!(c.action, Action::Index)
1792                    && c.index_def
1793                        .as_ref()
1794                        .is_some_and(|idx| idx.name == "idx_schedules_route_schedule")
1795            })
1796            .expect("unique index command should exist");
1797        let add_fk_idx = cmds
1798            .iter()
1799            .position(|c| matches!(c.action, Action::Alter) && c.table == "trips")
1800            .expect("composite FK ALTER command should exist");
1801
1802        assert!(schedules_idx < unique_idx);
1803        assert!(trips_idx < unique_idx);
1804        assert!(unique_idx < add_fk_idx);
1805
1806        let trips_cmd = cmds
1807            .iter()
1808            .find(|c| matches!(c.action, Action::Make) && c.table == "trips")
1809            .expect("trips create command should exist");
1810        assert!(
1811            trips_cmd.table_constraints.is_empty(),
1812            "composite foreign keys should not be emitted inline on CREATE TABLE"
1813        );
1814
1815        let add_fk_cmd = &cmds[add_fk_idx];
1816        assert!(
1817            add_fk_cmd
1818                .table_constraints
1819                .iter()
1820                .any(|constraint| matches!(
1821                    constraint,
1822                    crate::ast::TableConstraint::ForeignKey {
1823                        columns,
1824                        ref_table,
1825                        ref_columns,
1826                        ..
1827                    } if columns == &["route_id", "schedule_id"]
1828                        && ref_table == "schedules"
1829                        && ref_columns == &["route_id", "schedule_id"]
1830                )),
1831            "diff should preserve composite FK table constraint"
1832        );
1833
1834        let sql = add_fk_cmd.to_sql();
1835        assert!(
1836            sql.contains(
1837                "ALTER TABLE trips ADD FOREIGN KEY (route_id, schedule_id) REFERENCES schedules(route_id, schedule_id)"
1838            ),
1839            "generated SQL should include composite foreign key, got: {sql}"
1840        );
1841    }
1842
1843    #[test]
1844    fn diff_existing_table_adds_multi_column_foreign_key() {
1845        use super::super::types::ColumnType;
1846        use crate::transpiler::ToSql;
1847
1848        let mut old = Schema::default();
1849        old.add_table(
1850            Table::new("schedules")
1851                .column(Column::new("route_id", ColumnType::Text))
1852                .column(Column::new("schedule_id", ColumnType::Text)),
1853        );
1854        old.add_table(
1855            Table::new("trips")
1856                .column(Column::new("route_id", ColumnType::Text))
1857                .column(Column::new("schedule_id", ColumnType::Text)),
1858        );
1859
1860        let mut new = old.clone();
1861        new.add_index(
1862            Index::new(
1863                "idx_schedules_route_schedule",
1864                "schedules",
1865                vec!["route_id".to_string(), "schedule_id".to_string()],
1866            )
1867            .unique(),
1868        );
1869        new.tables
1870            .get_mut("trips")
1871            .expect("trips table should exist")
1872            .multi_column_fks
1873            .push(MultiColumnForeignKey::new(
1874                vec!["route_id".to_string(), "schedule_id".to_string()],
1875                "schedules",
1876                vec!["route_id".to_string(), "schedule_id".to_string()],
1877            ));
1878
1879        let cmds = diff_schemas(&old, &new);
1880        let unique_idx = cmds
1881            .iter()
1882            .position(|c| {
1883                matches!(c.action, Action::Index)
1884                    && c.index_def
1885                        .as_ref()
1886                        .is_some_and(|idx| idx.name == "idx_schedules_route_schedule")
1887            })
1888            .expect("unique index command should exist");
1889        let add_fk_idx = cmds
1890            .iter()
1891            .position(|c| matches!(c.action, Action::Alter) && c.table == "trips")
1892            .expect("composite FK ALTER command should exist");
1893        assert!(unique_idx < add_fk_idx);
1894
1895        let add_fk_cmd = &cmds[add_fk_idx];
1896        let sql = add_fk_cmd.to_sql();
1897        assert!(
1898            sql.contains(
1899                "ALTER TABLE trips ADD FOREIGN KEY (route_id, schedule_id) REFERENCES schedules(route_id, schedule_id)"
1900            ),
1901            "generated SQL should add composite foreign key, got: {sql}"
1902        );
1903    }
1904}