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