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 super::types::ColumnType;
11use crate::ast::{Action, ColumnGeneration, Constraint, Expr, IndexDef, Qail};
12use std::collections::BTreeSet;
13
14/// Return unsupported non-table object families present in a schema.
15///
16/// State-based diff currently covers table/index/migration-hint operations only.
17fn unsupported_state_diff_features(schema: &Schema) -> BTreeSet<&'static str> {
18    let mut out = BTreeSet::new();
19    if !schema.extensions.is_empty() {
20        out.insert("extensions");
21    }
22    if !schema.comments.is_empty() {
23        out.insert("comments");
24    }
25    if !schema.sequences.is_empty() {
26        out.insert("sequences");
27    }
28    if !schema.enums.is_empty() {
29        out.insert("enums");
30    }
31    if !schema.views.is_empty() {
32        out.insert("views");
33    }
34    if !schema.functions.is_empty() {
35        out.insert("functions");
36    }
37    if !schema.triggers.is_empty() {
38        out.insert("triggers");
39    }
40    if !schema.grants.is_empty() {
41        out.insert("grants");
42    }
43    if !schema.policies.is_empty() {
44        out.insert("policies");
45    }
46    if !schema.resources.is_empty() {
47        out.insert("resources");
48    }
49    out
50}
51
52fn unconfirmed_drop_hints(schema: &Schema) -> Vec<String> {
53    let mut hints = schema
54        .migrations
55        .iter()
56        .filter_map(|hint| match hint {
57            MigrationHint::Drop {
58                target,
59                confirmed: false,
60            } => Some(target.clone()),
61            _ => None,
62        })
63        .collect::<Vec<_>>();
64    hints.sort();
65    hints
66}
67
68fn validate_schema_for_state_diff(label: &str, schema: &Schema) -> Result<(), String> {
69    schema.validate().map_err(|errors| {
70        format!(
71            "State-based diff cannot use invalid {label} schema:\n{}",
72            errors.join("\n")
73        )
74    })
75}
76
77fn existing_column_check_diffs(old: &Schema, new: &Schema) -> Vec<String> {
78    let mut changes = Vec::new();
79
80    for (table_name, new_table) in &new.tables {
81        let Some(old_table) = old.tables.get(table_name) else {
82            continue;
83        };
84
85        let old_column_names = old_table
86            .columns
87            .iter()
88            .map(|column| column.name.as_str())
89            .collect::<std::collections::BTreeSet<_>>();
90        let new_column_names = new_table
91            .columns
92            .iter()
93            .map(|column| column.name.as_str())
94            .collect::<std::collections::BTreeSet<_>>();
95        let existing_column_names = old_column_names
96            .intersection(&new_column_names)
97            .copied()
98            .collect::<std::collections::BTreeSet<_>>();
99
100        let old_checks = table_check_signatures(old_table, &existing_column_names);
101        let new_checks = table_check_signatures(new_table, &existing_column_names);
102
103        for (signature, columns) in &new_checks {
104            if !old_checks.contains_key(signature) {
105                changes.push(format!(
106                    "{}.{} (new CHECK not present in old schema: {})",
107                    table_name,
108                    columns.join("|"),
109                    signature
110                ));
111            }
112        }
113
114        for (signature, columns) in &old_checks {
115            if !new_checks.contains_key(signature) {
116                changes.push(format!(
117                    "{}.{} (old CHECK not present in new schema: {})",
118                    table_name,
119                    columns.join("|"),
120                    signature
121                ));
122            }
123        }
124    }
125
126    changes.sort();
127    changes.dedup();
128    changes
129}
130
131fn table_check_signatures(
132    table: &super::schema::Table,
133    existing_column_names: &std::collections::BTreeSet<&str>,
134) -> std::collections::BTreeMap<String, Vec<String>> {
135    let mut signatures = std::collections::BTreeMap::<String, Vec<String>>::new();
136    for column in table
137        .columns
138        .iter()
139        .filter(|column| existing_column_names.contains(column.name.as_str()))
140    {
141        for signature in check_signatures(column) {
142            signatures
143                .entry(signature)
144                .or_default()
145                .push(column.name.clone());
146        }
147    }
148    signatures
149}
150
151fn existing_column_foreign_key_diffs(old: &Schema, new: &Schema) -> Vec<String> {
152    let mut changes = Vec::new();
153
154    for (table_name, new_table) in &new.tables {
155        let Some(old_table) = old.tables.get(table_name) else {
156            continue;
157        };
158
159        for new_col in &new_table.columns {
160            let Some(old_col) = old_table
161                .columns
162                .iter()
163                .find(|old_col| old_col.name == new_col.name)
164            else {
165                continue;
166            };
167
168            if foreign_key_signature(&old_col.foreign_key)
169                != foreign_key_signature(&new_col.foreign_key)
170            {
171                changes.push(format!("{}.{}", table_name, new_col.name));
172            }
173        }
174    }
175
176    changes.sort();
177    changes
178}
179
180fn removed_or_changed_multi_column_foreign_keys(old: &Schema, new: &Schema) -> Vec<String> {
181    let mut changes = Vec::new();
182
183    for (table_name, old_table) in &old.tables {
184        let Some(new_table) = new.tables.get(table_name) else {
185            continue;
186        };
187
188        for old_fk in &old_table.multi_column_fks {
189            if !new_table.multi_column_fks.contains(old_fk) {
190                changes.push(format!(
191                    "{}.{}",
192                    table_name,
193                    multi_column_fk_signature(old_fk)
194                ));
195            }
196        }
197    }
198
199    changes.sort();
200    changes
201}
202
203fn added_multi_column_foreign_keys_on_existing_tables(old: &Schema, new: &Schema) -> Vec<String> {
204    let mut changes = Vec::new();
205
206    for (table_name, new_table) in &new.tables {
207        let Some(old_table) = old.tables.get(table_name) else {
208            continue;
209        };
210
211        for new_fk in &new_table.multi_column_fks {
212            if !old_table.multi_column_fks.contains(new_fk) {
213                changes.push(format!(
214                    "{}.{}",
215                    table_name,
216                    multi_column_fk_signature(new_fk)
217                ));
218            }
219        }
220    }
221
222    changes.sort();
223    changes.dedup();
224    changes
225}
226
227fn existing_column_unique_diffs(old: &Schema, new: &Schema) -> Vec<String> {
228    let mut changes = Vec::new();
229
230    for (table_name, new_table) in &new.tables {
231        let Some(old_table) = old.tables.get(table_name) else {
232            continue;
233        };
234
235        for new_col in &new_table.columns {
236            let Some(old_col) = old_table
237                .columns
238                .iter()
239                .find(|old_col| old_col.name == new_col.name)
240            else {
241                continue;
242            };
243
244            if old_col.unique != new_col.unique {
245                changes.push(format!("{}.{}", table_name, new_col.name));
246            }
247        }
248    }
249
250    changes.sort();
251    changes
252}
253
254fn existing_column_primary_key_diffs(old: &Schema, new: &Schema) -> Vec<String> {
255    let mut changes = Vec::new();
256
257    for (table_name, new_table) in &new.tables {
258        let Some(old_table) = old.tables.get(table_name) else {
259            continue;
260        };
261
262        for new_col in &new_table.columns {
263            let Some(old_col) = old_table
264                .columns
265                .iter()
266                .find(|old_col| old_col.name == new_col.name)
267            else {
268                continue;
269            };
270
271            if old_col.primary_key != new_col.primary_key {
272                changes.push(format!("{}.{}", table_name, new_col.name));
273            }
274        }
275    }
276
277    changes.sort();
278    changes
279}
280
281fn existing_column_set_not_null_diffs(old: &Schema, new: &Schema) -> Vec<String> {
282    let mut changes = Vec::new();
283
284    for (table_name, new_table) in &new.tables {
285        let Some(old_table) = old.tables.get(table_name) else {
286            continue;
287        };
288
289        for new_col in &new_table.columns {
290            let Some(old_col) = old_table
291                .columns
292                .iter()
293                .find(|old_col| old_col.name == new_col.name)
294            else {
295                continue;
296            };
297
298            if old_col.nullable && !new_col.nullable && !new_col.primary_key {
299                changes.push(format!("{}.{}", table_name, new_col.name));
300            }
301        }
302    }
303
304    changes.sort();
305    changes
306}
307
308fn existing_column_generated_diffs(old: &Schema, new: &Schema) -> Vec<String> {
309    let mut changes = Vec::new();
310
311    for (table_name, new_table) in &new.tables {
312        let Some(old_table) = old.tables.get(table_name) else {
313            continue;
314        };
315
316        for new_col in &new_table.columns {
317            let Some(old_col) = old_table
318                .columns
319                .iter()
320                .find(|old_col| old_col.name == new_col.name)
321            else {
322                continue;
323            };
324
325            if generated_signature(&old_col.generated) != generated_signature(&new_col.generated) {
326                changes.push(format!("{}.{}", table_name, new_col.name));
327            }
328        }
329    }
330
331    changes.sort();
332    changes
333}
334
335fn unsupported_existing_column_type_diffs(old: &Schema, new: &Schema) -> Vec<String> {
336    let mut changes = Vec::new();
337
338    for (table_name, new_table) in &new.tables {
339        let Some(old_table) = old.tables.get(table_name) else {
340            continue;
341        };
342
343        for new_col in &new_table.columns {
344            let Some(old_col) = old_table
345                .columns
346                .iter()
347                .find(|old_col| old_col.name == new_col.name)
348            else {
349                continue;
350            };
351
352            if !column_types_equivalent_for_diff(&old_col.data_type, &new_col.data_type)
353                && !is_safe_existing_column_type_change(&old_col.data_type, &new_col.data_type)
354            {
355                changes.push(format!(
356                    "{}.{} ({} -> {})",
357                    table_name,
358                    new_col.name,
359                    old_col.data_type.to_pg_type(),
360                    new_col.data_type.to_pg_type()
361                ));
362            }
363        }
364    }
365
366    changes.sort();
367    changes
368}
369
370fn is_safe_existing_column_type_change(old: &ColumnType, new: &ColumnType) -> bool {
371    if column_types_equivalent_for_diff(old, new) {
372        return true;
373    }
374
375    if is_serial_pseudo_type(old) || is_serial_pseudo_type(new) {
376        return false;
377    }
378
379    match (old, new) {
380        (ColumnType::Int, ColumnType::BigInt) => true,
381        (old, ColumnType::Text) if is_unbounded_character_type(old) => true,
382        (ColumnType::Text, ColumnType::Varchar(None)) => true,
383        (ColumnType::Varchar(None), ColumnType::Text) => true,
384        (ColumnType::Varchar(Some(old_len)), ColumnType::Varchar(Some(new_len))) => {
385            new_len >= old_len
386        }
387        (ColumnType::Varchar(Some(_)), ColumnType::Varchar(None)) => true,
388        (ColumnType::Decimal(Some(_)), ColumnType::Decimal(None)) => true,
389        (
390            ColumnType::Decimal(Some((old_precision, old_scale))),
391            ColumnType::Decimal(Some((new_precision, new_scale))),
392        ) => {
393            let old_integer_digits = old_precision.saturating_sub(*old_scale);
394            let new_integer_digits = new_precision.saturating_sub(*new_scale);
395            new_integer_digits >= old_integer_digits && new_scale >= old_scale
396        }
397        (old, ColumnType::Int | ColumnType::BigInt) if is_smallint_type(old) => true,
398        _ => false,
399    }
400}
401
402fn column_types_equivalent_for_diff(old: &ColumnType, new: &ColumnType) -> bool {
403    if old == new {
404        return true;
405    }
406
407    match (old, new) {
408        (ColumnType::Array(old_inner), ColumnType::Array(new_inner)) => {
409            column_types_equivalent_for_diff(old_inner, new_inner)
410        }
411        (ColumnType::Enum { name: old_name, .. }, ColumnType::Enum { name: new_name, .. })
412        | (ColumnType::Enum { name: old_name, .. }, ColumnType::Range(new_name))
413        | (ColumnType::Range(old_name), ColumnType::Enum { name: new_name, .. })
414        | (ColumnType::Range(old_name), ColumnType::Range(new_name)) => {
415            old_name.eq_ignore_ascii_case(new_name)
416        }
417        _ => false,
418    }
419}
420
421fn is_serial_pseudo_type(ty: &ColumnType) -> bool {
422    matches!(ty, ColumnType::Serial | ColumnType::BigSerial)
423}
424
425fn is_unbounded_character_type(ty: &ColumnType) -> bool {
426    matches!(ty, ColumnType::Varchar(_) | ColumnType::Text)
427}
428
429fn is_smallint_type(ty: &ColumnType) -> bool {
430    matches!(ty, ColumnType::Range(name) if name.eq_ignore_ascii_case("SMALLINT"))
431}
432
433fn new_column_primary_key_additions(old: &Schema, new: &Schema) -> Vec<String> {
434    let mut changes = Vec::new();
435
436    for (table_name, new_table) in &new.tables {
437        let Some(old_table) = old.tables.get(table_name) else {
438            continue;
439        };
440
441        for new_col in &new_table.columns {
442            if new_col.primary_key
443                && !old_table
444                    .columns
445                    .iter()
446                    .any(|old_col| old_col.name == new_col.name)
447            {
448                changes.push(format!("{}.{}", table_name, new_col.name));
449            }
450        }
451    }
452
453    changes.sort();
454    changes
455}
456
457fn new_serial_pseudo_type_column_additions(old: &Schema, new: &Schema) -> Vec<String> {
458    let mut changes = Vec::new();
459
460    for (table_name, new_table) in &new.tables {
461        let Some(old_table) = old.tables.get(table_name) else {
462            continue;
463        };
464
465        for new_col in &new_table.columns {
466            if is_serial_pseudo_type(&new_col.data_type)
467                && !old_table
468                    .columns
469                    .iter()
470                    .any(|old_col| old_col.name == new_col.name)
471            {
472                changes.push(format!("{}.{}", table_name, new_col.name));
473            }
474        }
475    }
476
477    changes.sort();
478    changes
479}
480
481fn new_required_column_additions_without_value(old: &Schema, new: &Schema) -> Vec<String> {
482    let mut changes = Vec::new();
483
484    for (table_name, new_table) in &new.tables {
485        let Some(old_table) = old.tables.get(table_name) else {
486            continue;
487        };
488
489        for new_col in &new_table.columns {
490            if !new_col.nullable
491                && !new_col.primary_key
492                && !column_has_value_source(new_col)
493                && !old_table
494                    .columns
495                    .iter()
496                    .any(|old_col| old_col.name == new_col.name)
497            {
498                changes.push(format!("{}.{}", table_name, new_col.name));
499            }
500        }
501    }
502
503    changes.sort();
504    changes
505}
506
507fn new_unique_column_additions_with_value(old: &Schema, new: &Schema) -> Vec<String> {
508    let mut changes = Vec::new();
509
510    for (table_name, new_table) in &new.tables {
511        let Some(old_table) = old.tables.get(table_name) else {
512            continue;
513        };
514
515        for new_col in new_columns(old_table, new_table) {
516            if new_col.unique && column_has_value_source(new_col) {
517                changes.push(format!("{}.{}", table_name, new_col.name));
518            }
519        }
520    }
521
522    changes.sort();
523    changes
524}
525
526fn new_foreign_key_column_additions_with_value(old: &Schema, new: &Schema) -> Vec<String> {
527    let mut changes = Vec::new();
528
529    for (table_name, new_table) in &new.tables {
530        let Some(old_table) = old.tables.get(table_name) else {
531            continue;
532        };
533
534        for new_col in new_columns(old_table, new_table) {
535            if new_col.foreign_key.is_some() && column_has_value_source(new_col) {
536                changes.push(format!("{}.{}", table_name, new_col.name));
537            }
538        }
539    }
540
541    changes.sort();
542    changes
543}
544
545fn new_check_column_additions_requiring_validation(old: &Schema, new: &Schema) -> Vec<String> {
546    let mut changes = Vec::new();
547
548    for (table_name, new_table) in &new.tables {
549        let Some(old_table) = old.tables.get(table_name) else {
550            continue;
551        };
552
553        for new_col in new_columns(old_table, new_table) {
554            let checks = new_col.checks().collect::<Vec<_>>();
555            if checks.is_empty() {
556                continue;
557            }
558
559            if column_has_value_source(new_col)
560                || checks
561                    .iter()
562                    .any(|check| check_expr_requires_existing_row_validation(&check.expr))
563            {
564                changes.push(format!("{}.{}", table_name, new_col.name));
565            }
566        }
567    }
568
569    changes.sort();
570    changes
571}
572
573fn new_columns<'a>(
574    old_table: &'a super::schema::Table,
575    new_table: &'a super::schema::Table,
576) -> impl Iterator<Item = &'a super::schema::Column> {
577    new_table.columns.iter().filter(|new_col| {
578        !old_table
579            .columns
580            .iter()
581            .any(|old_col| old_col.name == new_col.name)
582    })
583}
584
585fn column_has_value_source(column: &super::schema::Column) -> bool {
586    column.default.is_some() || column.generated.is_some()
587}
588
589fn check_expr_requires_existing_row_validation(expr: &super::schema::CheckExpr) -> bool {
590    match expr {
591        super::schema::CheckExpr::NotNull { .. } | super::schema::CheckExpr::Sql(_) => true,
592        super::schema::CheckExpr::And(left, right) | super::schema::CheckExpr::Or(left, right) => {
593            check_expr_requires_existing_row_validation(left)
594                || check_expr_requires_existing_row_validation(right)
595        }
596        super::schema::CheckExpr::Not(inner) => check_expr_requires_existing_row_validation(inner),
597        _ => false,
598    }
599}
600
601fn same_name_index_definition_diffs(old: &Schema, new: &Schema) -> Vec<String> {
602    let mut changes = Vec::new();
603
604    for new_idx in &new.indexes {
605        let Some(old_idx) = old
606            .indexes
607            .iter()
608            .find(|old_idx| old_idx.name == new_idx.name)
609        else {
610            continue;
611        };
612
613        let reasons = index_difference_reasons(old_idx, new_idx);
614        if !reasons.is_empty() {
615            changes.push(format!("{} ({})", new_idx.name, reasons.join("; ")));
616        }
617    }
618
619    changes.sort();
620    changes.dedup();
621    changes
622}
623
624fn existing_table_rls_downgrades(old: &Schema, new: &Schema) -> Vec<String> {
625    let mut changes = Vec::new();
626
627    for (table_name, old_table) in &old.tables {
628        let Some(new_table) = new.tables.get(table_name) else {
629            continue;
630        };
631
632        if old_table.enable_rls && !new_table.enable_rls {
633            changes.push(format!("{table_name} (disable RLS)"));
634        }
635        if old_table.force_rls && !new_table.force_rls {
636            changes.push(format!("{table_name} (drop FORCE RLS)"));
637        }
638    }
639
640    changes.sort();
641    changes
642}
643
644fn check_signatures(column: &super::schema::Column) -> Vec<String> {
645    column
646        .checks()
647        .map(|check| normalize_index_sql_fragment(&check_expr_to_sql(&check.expr)))
648        .collect()
649}
650
651fn foreign_key_signature(fk: &Option<super::schema::ForeignKey>) -> Option<String> {
652    fk.as_ref().map(|fk| format!("{:?}", fk))
653}
654
655fn multi_column_fk_signature(fk: &super::schema::MultiColumnForeignKey) -> String {
656    match &fk.name {
657        Some(name) => format!("constraint:{name}"),
658        None => format!("{:?}->{:?}.{:?}", fk.columns, fk.ref_table, fk.ref_columns),
659    }
660}
661
662fn generated_signature(generated: &Option<Generated>) -> Option<String> {
663    match generated {
664        Some(Generated::AlwaysStored(expr)) => Some(format!("stored:{expr}")),
665        Some(Generated::AlwaysIdentity) => Some("identity:always".to_string()),
666        Some(Generated::ByDefaultIdentity) => Some("identity:by_default".to_string()),
667        None => None,
668    }
669}
670
671fn generated_to_constraint(generated: &Generated) -> Constraint {
672    match generated {
673        Generated::AlwaysStored(expr) => {
674            Constraint::Generated(ColumnGeneration::Stored(expr.clone()))
675        }
676        Generated::AlwaysIdentity => {
677            Constraint::Generated(ColumnGeneration::Stored("identity".to_string()))
678        }
679        Generated::ByDefaultIdentity => {
680            Constraint::Generated(ColumnGeneration::Stored("identity_by_default".to_string()))
681        }
682    }
683}
684
685#[derive(Debug, PartialEq, Eq)]
686struct ComparableIndex {
687    table: String,
688    columns: Vec<String>,
689    expressions: Vec<String>,
690    unique: bool,
691    method: &'static str,
692    where_clause: Option<String>,
693    include: Vec<String>,
694}
695
696fn comparable_index(idx: &super::schema::Index) -> ComparableIndex {
697    ComparableIndex {
698        table: idx.table.clone(),
699        columns: normalized_index_fragments(&idx.columns),
700        expressions: normalized_index_fragments(&idx.expressions),
701        unique: idx.unique,
702        method: index_method_str(&idx.method),
703        where_clause: idx
704            .where_clause
705            .as_ref()
706            .map(check_expr_to_sql)
707            .map(|fragment| normalize_index_sql_fragment(&fragment)),
708        include: normalized_index_fragments(&idx.include),
709    }
710}
711
712fn index_difference_reasons(
713    old_idx: &super::schema::Index,
714    new_idx: &super::schema::Index,
715) -> Vec<String> {
716    let old = comparable_index(old_idx);
717    let new = comparable_index(new_idx);
718    let mut reasons = Vec::new();
719
720    push_index_diff(&mut reasons, "table", &old.table, &new.table);
721    push_index_diff(&mut reasons, "columns", &old.columns, &new.columns);
722    push_index_diff(
723        &mut reasons,
724        "expressions",
725        &old.expressions,
726        &new.expressions,
727    );
728    push_index_diff(&mut reasons, "unique", &old.unique, &new.unique);
729    push_index_diff(&mut reasons, "method", &old.method, &new.method);
730    push_index_diff(&mut reasons, "where", &old.where_clause, &new.where_clause);
731    push_index_diff(&mut reasons, "include", &old.include, &new.include);
732
733    reasons
734}
735
736fn push_index_diff<T>(reasons: &mut Vec<String>, label: &str, old: &T, new: &T)
737where
738    T: std::fmt::Debug + PartialEq,
739{
740    if old != new {
741        reasons.push(format!("{label}: {old:?} -> {new:?}"));
742    }
743}
744
745fn normalized_index_fragments(values: &[String]) -> Vec<String> {
746    values
747        .iter()
748        .map(|value| normalize_index_sql_fragment(value))
749        .collect()
750}
751
752fn normalize_index_sql_fragment(input: &str) -> String {
753    let mut normalized = compact_sql_for_index_compare(input);
754    normalized = normalized.replace("!=", "<>");
755    normalized = normalized.replace("::charactervarying", "");
756    normalized = normalized.replace("::varchar", "");
757    normalized = normalized.replace("::text", "");
758    normalized = unquote_simple_lowercase_identifiers(&normalized);
759
760    loop {
761        let stripped = strip_redundant_outer_parens(&normalized);
762        let simplified = simplify_parenthesized_identifiers(&stripped);
763        if simplified == normalized {
764            normalized = simplified;
765            break;
766        }
767        normalized = simplified;
768    }
769
770    normalize_any_array_predicate(&normalized)
771}
772
773fn unquote_simple_lowercase_identifiers(input: &str) -> String {
774    let mut out = String::new();
775    let mut chars = input.char_indices().peekable();
776    let mut last = 0usize;
777    let mut in_single = false;
778
779    while let Some((idx, ch)) = chars.next() {
780        if ch == '\'' {
781            if in_single && chars.peek().is_some_and(|(_, next)| *next == '\'') {
782                chars.next();
783                continue;
784            }
785            in_single = !in_single;
786            continue;
787        }
788        if in_single {
789            continue;
790        }
791        if ch != '"' {
792            continue;
793        }
794
795        out.push_str(&input[last..idx]);
796        let content_start = idx + ch.len_utf8();
797        let mut content = String::new();
798        let mut end = None;
799        while let Some((inner_idx, inner_ch)) = chars.next() {
800            if inner_ch == '"' {
801                if chars.peek().is_some_and(|(_, next)| *next == '"') {
802                    chars.next();
803                    content.push('"');
804                    continue;
805                }
806                end = Some(inner_idx);
807                break;
808            }
809            content.push(inner_ch);
810        }
811
812        let Some(end_idx) = end else {
813            out.push('"');
814            out.push_str(&input[content_start..]);
815            return out;
816        };
817
818        if is_simple_lowercase_identifier(&content) {
819            out.push_str(&content);
820        } else {
821            out.push('"');
822            out.push_str(&input[content_start..end_idx]);
823            out.push('"');
824        }
825        last = end_idx + 1;
826    }
827
828    out.push_str(&input[last..]);
829    out
830}
831
832fn is_simple_lowercase_identifier(value: &str) -> bool {
833    let mut chars = value.chars();
834    matches!(chars.next(), Some(ch) if ch.is_ascii_lowercase() || ch == '_')
835        && chars.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_')
836}
837
838fn compact_sql_for_index_compare(input: &str) -> String {
839    let mut out = String::new();
840    let mut in_single = false;
841    let mut in_double = false;
842    let mut chars = input.trim().chars().peekable();
843
844    while let Some(ch) = chars.next() {
845        match ch {
846            '\'' if !in_double => {
847                out.push(ch);
848                if in_single && chars.peek().is_some_and(|next| *next == '\'') {
849                    out.push('\'');
850                    chars.next();
851                } else {
852                    in_single = !in_single;
853                }
854            }
855            '"' if !in_single => {
856                out.push(ch);
857                if in_double && chars.peek().is_some_and(|next| *next == '"') {
858                    out.push('"');
859                    chars.next();
860                } else {
861                    in_double = !in_double;
862                }
863            }
864            _ if !in_single && !in_double && ch.is_whitespace() => {}
865            _ if !in_single && !in_double => out.extend(ch.to_lowercase()),
866            _ => out.push(ch),
867        }
868    }
869
870    out
871}
872
873fn strip_redundant_outer_parens(input: &str) -> String {
874    let mut s = input;
875    while s.starts_with('(') && s.ends_with(')') && outer_parens_wrap_entire_fragment(s) {
876        s = &s[1..s.len() - 1];
877    }
878    s.to_string()
879}
880
881fn outer_parens_wrap_entire_fragment(input: &str) -> bool {
882    let mut depth = 0_i32;
883    let mut in_single = false;
884    let mut in_double = false;
885    let mut chars = input.char_indices().peekable();
886
887    while let Some((idx, ch)) = chars.next() {
888        match ch {
889            '\'' if !in_double => {
890                if in_single && chars.peek().is_some_and(|(_, next)| *next == '\'') {
891                    chars.next();
892                } else {
893                    in_single = !in_single;
894                }
895            }
896            '"' if !in_single => {
897                if in_double && chars.peek().is_some_and(|(_, next)| *next == '"') {
898                    chars.next();
899                } else {
900                    in_double = !in_double;
901                }
902            }
903            '(' if !in_single && !in_double => depth += 1,
904            ')' if !in_single && !in_double => {
905                depth -= 1;
906                if depth == 0 && idx != input.len() - 1 {
907                    return false;
908                }
909            }
910            _ => {}
911        }
912    }
913
914    depth == 0 && !in_single && !in_double
915}
916
917fn simplify_parenthesized_identifiers(input: &str) -> String {
918    let mut out = String::new();
919    let chars: Vec<char> = input.chars().collect();
920    let mut idx = 0;
921
922    while idx < chars.len() {
923        if chars[idx] != '(' {
924            out.push(chars[idx]);
925            idx += 1;
926            continue;
927        }
928
929        let Some(end) = matching_paren_chars(&chars, idx) else {
930            out.push(chars[idx]);
931            idx += 1;
932            continue;
933        };
934        let inner: String = chars[idx + 1..end].iter().collect();
935        let preceded_by_identifier = idx > 0 && is_compact_identifier_char(chars[idx - 1]);
936        if !preceded_by_identifier && is_compact_identifier_path(&inner) {
937            out.push_str(&inner);
938            idx = end + 1;
939        } else {
940            out.push(chars[idx]);
941            idx += 1;
942        }
943    }
944
945    out
946}
947
948fn matching_paren_chars(chars: &[char], start: usize) -> Option<usize> {
949    let mut depth = 0_i32;
950    let mut in_single = false;
951    let mut in_double = false;
952    let mut idx = start;
953
954    while idx < chars.len() {
955        match chars[idx] {
956            '\'' if !in_double => {
957                if in_single && chars.get(idx + 1).is_some_and(|next| *next == '\'') {
958                    idx += 1;
959                } else {
960                    in_single = !in_single;
961                }
962            }
963            '"' if !in_single => {
964                if in_double && chars.get(idx + 1).is_some_and(|next| *next == '"') {
965                    idx += 1;
966                } else {
967                    in_double = !in_double;
968                }
969            }
970            '(' if !in_single && !in_double => depth += 1,
971            ')' if !in_single && !in_double => {
972                depth -= 1;
973                if depth == 0 {
974                    return Some(idx);
975                }
976            }
977            _ => {}
978        }
979        idx += 1;
980    }
981
982    None
983}
984
985fn is_compact_identifier_path(input: &str) -> bool {
986    !input.is_empty() && input.chars().all(is_compact_identifier_char)
987}
988
989fn is_compact_identifier_char(ch: char) -> bool {
990    ch.is_ascii_alphanumeric() || ch == '_' || ch == '.'
991}
992
993fn normalize_any_array_predicate(input: &str) -> String {
994    const ANY_ARRAY: &str = "=any(array[";
995
996    let Some(pos) = input.find(ANY_ARRAY) else {
997        return input.to_string();
998    };
999    if !input.ends_with("])") {
1000        return input.to_string();
1001    }
1002
1003    let left = &input[..pos];
1004    let values = &input[pos + ANY_ARRAY.len()..input.len() - 2];
1005    format!("{left}in({values})")
1006}
1007
1008fn table_references_table(table: &super::schema::Table, target: &str) -> bool {
1009    table.columns.iter().any(|col| {
1010        col.foreign_key
1011            .as_ref()
1012            .is_some_and(|fk| fk.table == target)
1013    }) || table
1014        .multi_column_fks
1015        .iter()
1016        .any(|fk| fk.ref_table == target)
1017}
1018
1019/// Validate that a schema pair is fully supported by state-based diff.
1020///
1021/// Returns an error when object families outside table/index/hint coverage are present.
1022pub fn validate_state_diff_support(old: &Schema, new: &Schema) -> Result<(), String> {
1023    validate_schema_for_state_diff("source", old)?;
1024    validate_schema_for_state_diff("target", new)?;
1025
1026    let mut unsupported = unsupported_state_diff_features(old);
1027    unsupported.extend(unsupported_state_diff_features(new));
1028
1029    if !unsupported.is_empty() {
1030        let detail = unsupported.into_iter().collect::<Vec<_>>().join(", ");
1031        return Err(format!(
1032            "State-based diff currently supports tables, columns, indexes, and migration hints only. \
1033             Unsupported schema object families present: {}. \
1034             Use folder-based strict migrations for these objects.",
1035            detail
1036        ));
1037    }
1038
1039    let unconfirmed_drops = unconfirmed_drop_hints(new);
1040    if !unconfirmed_drops.is_empty() {
1041        return Err(format!(
1042            "State-based diff refuses unconfirmed destructive drop hints: {}. \
1043             Add `confirm` to the drop hint or restore the object.",
1044            unconfirmed_drops.join(", ")
1045        ));
1046    }
1047
1048    let index_diffs = same_name_index_definition_diffs(old, new);
1049    if !index_diffs.is_empty() {
1050        return Err(format!(
1051            "State-based diff cannot safely replace existing indexes with changed definitions: {}. \
1052             Use an explicit migration for DROP INDEX/CREATE INDEX replacement.",
1053            index_diffs.join(", ")
1054        ));
1055    }
1056
1057    let check_diffs = existing_column_check_diffs(old, new);
1058    if !check_diffs.is_empty() {
1059        return Err(format!(
1060            "State-based diff cannot safely alter CHECK constraints on existing columns: {}. \
1061             Use an explicit migration for ADD/DROP/replace CHECK constraints.",
1062            check_diffs.join(", ")
1063        ));
1064    }
1065
1066    let unique_diffs = existing_column_unique_diffs(old, new);
1067    if !unique_diffs.is_empty() {
1068        return Err(format!(
1069            "State-based diff cannot safely alter UNIQUE constraints on existing columns: {}. \
1070             Use an explicit migration for ADD/DROP/replace UNIQUE constraints.",
1071            unique_diffs.join(", ")
1072        ));
1073    }
1074
1075    let pk_diffs = existing_column_primary_key_diffs(old, new);
1076    if !pk_diffs.is_empty() {
1077        return Err(format!(
1078            "State-based diff cannot safely alter PRIMARY KEY constraints on existing columns: {}. \
1079             Use an explicit migration for ADD/DROP/replace PRIMARY KEY constraints.",
1080            pk_diffs.join(", ")
1081        ));
1082    }
1083
1084    let set_not_null_diffs = existing_column_set_not_null_diffs(old, new);
1085    if !set_not_null_diffs.is_empty() {
1086        return Err(format!(
1087            "State-based diff cannot safely set NOT NULL on existing columns: {}. \
1088             Use an explicit migration to backfill/validate data before SET NOT NULL.",
1089            set_not_null_diffs.join(", ")
1090        ));
1091    }
1092
1093    let type_diffs = unsupported_existing_column_type_diffs(old, new);
1094    if !type_diffs.is_empty() {
1095        return Err(format!(
1096            "State-based diff cannot safely alter existing column types without an explicit cast plan: {}. \
1097             Use an explicit migration with USING/backfill steps for narrowing casts, pseudo-type changes, or data-validating conversions.",
1098            type_diffs.join(", ")
1099        ));
1100    }
1101
1102    let new_pk_columns = new_column_primary_key_additions(old, new);
1103    if !new_pk_columns.is_empty() {
1104        return Err(format!(
1105            "State-based diff cannot safely add PRIMARY KEY columns to existing tables: {}. \
1106             Use an explicit migration to backfill data and add the PRIMARY KEY constraint.",
1107            new_pk_columns.join(", ")
1108        ));
1109    }
1110
1111    let new_serial_columns = new_serial_pseudo_type_column_additions(old, new);
1112    if !new_serial_columns.is_empty() {
1113        return Err(format!(
1114            "State-based diff cannot safely add SERIAL/BIGSERIAL columns to existing tables: {}. \
1115             Use an explicit migration to create the sequence/default or use an identity column plan.",
1116            new_serial_columns.join(", ")
1117        ));
1118    }
1119
1120    let new_required_columns = new_required_column_additions_without_value(old, new);
1121    if !new_required_columns.is_empty() {
1122        return Err(format!(
1123            "State-based diff cannot safely add required columns without a default/generated value to existing tables: {}. \
1124             Use an explicit migration to add the column nullable, backfill, then set NOT NULL.",
1125            new_required_columns.join(", ")
1126        ));
1127    }
1128
1129    let new_unique_value_columns = new_unique_column_additions_with_value(old, new);
1130    if !new_unique_value_columns.is_empty() {
1131        return Err(format!(
1132            "State-based diff cannot safely add UNIQUE columns with default/generated values to existing tables: {}. \
1133             Use an explicit migration to backfill distinct values before adding the UNIQUE constraint.",
1134            new_unique_value_columns.join(", ")
1135        ));
1136    }
1137
1138    let new_fk_value_columns = new_foreign_key_column_additions_with_value(old, new);
1139    if !new_fk_value_columns.is_empty() {
1140        return Err(format!(
1141            "State-based diff cannot safely add FOREIGN KEY columns with default/generated values to existing tables: {}. \
1142             Use an explicit migration to backfill valid references before adding the FOREIGN KEY constraint.",
1143            new_fk_value_columns.join(", ")
1144        ));
1145    }
1146
1147    let new_check_validation_columns = new_check_column_additions_requiring_validation(old, new);
1148    if !new_check_validation_columns.is_empty() {
1149        return Err(format!(
1150            "State-based diff cannot safely add CHECK constraints that may validate existing rows on new columns: {}. \
1151             Use an explicit migration to add/backfill/validate the CHECK constraint.",
1152            new_check_validation_columns.join(", ")
1153        ));
1154    }
1155
1156    let fk_diffs = existing_column_foreign_key_diffs(old, new);
1157    if !fk_diffs.is_empty() {
1158        return Err(format!(
1159            "State-based diff cannot safely alter single-column foreign keys on existing columns: {}. \
1160             Use an explicit migration for ADD/DROP/replace FOREIGN KEY constraints.",
1161            fk_diffs.join(", ")
1162        ));
1163    }
1164
1165    let multi_fk_diffs = removed_or_changed_multi_column_foreign_keys(old, new);
1166    if !multi_fk_diffs.is_empty() {
1167        return Err(format!(
1168            "State-based diff cannot safely drop or replace multi-column foreign keys on existing tables: {}. \
1169             Use an explicit migration for DROP CONSTRAINT/ADD CONSTRAINT replacement.",
1170            multi_fk_diffs.join(", ")
1171        ));
1172    }
1173
1174    let added_multi_fks = added_multi_column_foreign_keys_on_existing_tables(old, new);
1175    if !added_multi_fks.is_empty() {
1176        return Err(format!(
1177            "State-based diff cannot safely add multi-column foreign keys to existing tables: {}. \
1178             Use an explicit migration to validate/backfill references before ADD CONSTRAINT.",
1179            added_multi_fks.join(", ")
1180        ));
1181    }
1182
1183    let generated_diffs = existing_column_generated_diffs(old, new);
1184    if !generated_diffs.is_empty() {
1185        return Err(format!(
1186            "State-based diff cannot safely alter GENERATED/IDENTITY clauses on existing columns: {}. \
1187             Use an explicit migration for GENERATED/IDENTITY changes.",
1188            generated_diffs.join(", ")
1189        ));
1190    }
1191
1192    let rls_downgrades = existing_table_rls_downgrades(old, new);
1193    if !rls_downgrades.is_empty() {
1194        return Err(format!(
1195            "State-based diff cannot safely downgrade RLS on existing tables: {}. \
1196             Use an explicit migration for DISABLE ROW LEVEL SECURITY or NO FORCE ROW LEVEL SECURITY.",
1197            rls_downgrades.join(", ")
1198        ));
1199    }
1200
1201    Ok(())
1202}
1203
1204/// Checked variant of [`diff_schemas`] that rejects unsupported object families.
1205pub fn diff_schemas_checked(old: &Schema, new: &Schema) -> Result<Vec<Qail>, String> {
1206    validate_state_diff_support(old, new)?;
1207    Ok(diff_schemas(old, new))
1208}
1209
1210/// Compute the difference between two schemas.
1211/// Returns a `Vec<Qail>` representing the operations needed to migrate
1212/// from `old` to `new`. Respects MigrationHint for intent-aware diffing.
1213pub fn diff_schemas(old: &Schema, new: &Schema) -> Vec<Qail> {
1214    let mut cmds = Vec::new();
1215
1216    // Process migration hints first (intent-aware)
1217    for hint in &new.migrations {
1218        match hint {
1219            MigrationHint::Rename { from, to } => {
1220                if let (Some((from_table, from_col)), Some((to_table, to_col))) =
1221                    (parse_table_col(from), parse_table_col(to))
1222                    && from_table == to_table
1223                {
1224                    // Same table rename - use ALTER TABLE RENAME COLUMN
1225                    cmds.push(Qail {
1226                        action: Action::Mod,
1227                        table: from_table.to_string(),
1228                        columns: vec![Expr::Named(format!("{} -> {}", from_col, to_col))],
1229                        ..Default::default()
1230                    });
1231                }
1232            }
1233            MigrationHint::Transform { expression, target } => {
1234                if let Some((table, _col)) = parse_table_col(target) {
1235                    cmds.push(Qail {
1236                        action: Action::Set,
1237                        table: table.to_string(),
1238                        columns: vec![Expr::Named(format!("/* TRANSFORM: {} */", expression))],
1239                        ..Default::default()
1240                    });
1241                }
1242            }
1243            MigrationHint::Drop {
1244                target,
1245                confirmed: true,
1246            } => {
1247                if target.contains('.') {
1248                    // Drop column
1249                    if let Some((table, col)) = parse_table_col(target) {
1250                        cmds.push(Qail {
1251                            action: Action::AlterDrop,
1252                            table: table.to_string(),
1253                            columns: vec![Expr::Named(col.to_string())],
1254                            ..Default::default()
1255                        });
1256                    }
1257                } else {
1258                    // Drop table
1259                    cmds.push(Qail {
1260                        action: Action::Drop,
1261                        table: target.clone(),
1262                        ..Default::default()
1263                    });
1264                }
1265            }
1266            _ => {}
1267        }
1268    }
1269
1270    // Collect new tables (not in old schema), sorted by FK dependencies
1271    let new_table_names: Vec<&String> = new
1272        .tables
1273        .keys()
1274        .filter(|name| !old.tables.contains_key(*name))
1275        .collect();
1276
1277    // Simple FK-aware sort: tables with no FK deps first, then others
1278    // This handles the common case of parent -> child relationships
1279    // Use iterative topological sort: in each round, emit tables whose FK targets
1280    // are either already emitted or not in this batch (pre-existing tables).
1281    let new_set: std::collections::HashSet<&str> =
1282        new_table_names.iter().map(|n| n.as_str()).collect();
1283    let mut emitted: std::collections::HashSet<&str> = std::collections::HashSet::new();
1284    let mut sorted: Vec<&String> = Vec::with_capacity(new_table_names.len());
1285    let mut remaining = new_table_names;
1286
1287    loop {
1288        let before = sorted.len();
1289        remaining.retain(|name| {
1290            let deps_satisfied = new.tables.get(*name).is_none_or(|t| {
1291                t.columns.iter().all(|c| {
1292                    c.foreign_key.as_ref().is_none_or(|fk| {
1293                        !new_set.contains(fk.table.as_str()) || emitted.contains(fk.table.as_str())
1294                    })
1295                }) && t.multi_column_fks.iter().all(|fk| {
1296                    !new_set.contains(fk.ref_table.as_str())
1297                        || emitted.contains(fk.ref_table.as_str())
1298                })
1299            });
1300            if deps_satisfied {
1301                emitted.insert(name.as_str());
1302                sorted.push(name);
1303                false // remove from remaining
1304            } else {
1305                true // keep in remaining
1306            }
1307        });
1308        if remaining.is_empty() || sorted.len() == before {
1309            // Either done or circular deps — append remaining as-is
1310            sorted.extend(remaining);
1311            break;
1312        }
1313    }
1314
1315    let new_table_names = sorted;
1316
1317    // Generate CREATE TABLE commands in dependency order
1318    for name in new_table_names {
1319        let table = &new.tables[name];
1320        let columns: Vec<Expr> = table
1321            .columns
1322            .iter()
1323            .map(|col| {
1324                let mut constraints = Vec::new();
1325                if col.primary_key {
1326                    constraints.push(Constraint::PrimaryKey);
1327                }
1328                if col.nullable {
1329                    constraints.push(Constraint::Nullable);
1330                }
1331                if col.unique {
1332                    constraints.push(Constraint::Unique);
1333                }
1334                if let Some(def) = &col.default {
1335                    constraints.push(Constraint::Default(def.clone()));
1336                }
1337                if let Some(ref fk) = col.foreign_key {
1338                    constraints.push(Constraint::References(foreign_key_to_sql(fk)));
1339                }
1340                for check in col.checks() {
1341                    let check_sql = check_expr_to_sql(&check.expr);
1342                    if let Some(name) = &check.name {
1343                        constraints.push(Constraint::Check(vec![format!(
1344                            "CONSTRAINT {} CHECK ({})",
1345                            name, check_sql
1346                        )]));
1347                    } else {
1348                        constraints.push(Constraint::Check(vec![check_sql]));
1349                    }
1350                }
1351                if let Some(generated) = &col.generated {
1352                    constraints.push(generated_to_constraint(generated));
1353                }
1354
1355                Expr::Def {
1356                    name: col.name.clone(),
1357                    data_type: col.data_type.to_pg_type(),
1358                    constraints,
1359                }
1360            })
1361            .collect();
1362
1363        cmds.push(Qail {
1364            action: Action::Make,
1365            table: name.clone(),
1366            columns,
1367            ..Default::default()
1368        });
1369
1370        if table.enable_rls {
1371            cmds.push(Qail {
1372                action: Action::AlterEnableRls,
1373                table: name.clone(),
1374                ..Default::default()
1375            });
1376        }
1377        if table.force_rls {
1378            cmds.push(Qail {
1379                action: Action::AlterForceRls,
1380                table: name.clone(),
1381                ..Default::default()
1382            });
1383        }
1384    }
1385
1386    // Detect dropped tables (only if not already handled by hints)
1387    let mut dropped_tables: Vec<&String> = old
1388        .tables
1389        .keys()
1390        .filter(|name| {
1391            !new.tables.contains_key(*name) && !new.migrations.iter().any(
1392                |h| matches!(h, MigrationHint::Drop { target, confirmed: true } if target == *name),
1393            )
1394        })
1395        .collect();
1396
1397    dropped_tables.sort();
1398    let mut remaining = dropped_tables;
1399    let mut dropped_tables = Vec::with_capacity(remaining.len());
1400    while !remaining.is_empty() {
1401        let before = dropped_tables.len();
1402        let remaining_names: Vec<String> = remaining.iter().map(|name| (*name).clone()).collect();
1403        let mut next_remaining = Vec::new();
1404
1405        for name in remaining {
1406            let has_dropped_dependent = remaining_names.iter().any(|other| {
1407                other.as_str() != name.as_str()
1408                    && old
1409                        .tables
1410                        .get(other)
1411                        .is_some_and(|table| table_references_table(table, name))
1412            });
1413
1414            if has_dropped_dependent {
1415                next_remaining.push(name);
1416            } else {
1417                dropped_tables.push(name);
1418            }
1419        }
1420
1421        if dropped_tables.len() == before {
1422            next_remaining.sort();
1423            dropped_tables.extend(next_remaining);
1424            break;
1425        }
1426
1427        remaining = next_remaining;
1428    }
1429
1430    for name in dropped_tables {
1431        cmds.push(Qail {
1432            action: Action::Drop,
1433            table: name.clone(),
1434            ..Default::default()
1435        });
1436    }
1437
1438    // Detect column changes in existing tables
1439    for (name, new_table) in &new.tables {
1440        if let Some(old_table) = old.tables.get(name) {
1441            let old_cols: std::collections::HashSet<_> =
1442                old_table.columns.iter().map(|c| &c.name).collect();
1443            let new_cols: std::collections::HashSet<_> =
1444                new_table.columns.iter().map(|c| &c.name).collect();
1445
1446            // New columns
1447            for col in &new_table.columns {
1448                if !old_cols.contains(&col.name) {
1449                    let col_path = format!("{}.{}", name, col.name);
1450                    let is_rename_target = new
1451                        .migrations
1452                        .iter()
1453                        .any(|h| matches!(h, MigrationHint::Rename { to, .. } if to == &col_path));
1454
1455                    if !is_rename_target {
1456                        let mut constraints = Vec::new();
1457                        if col.nullable {
1458                            constraints.push(Constraint::Nullable);
1459                        }
1460                        if col.unique {
1461                            constraints.push(Constraint::Unique);
1462                        }
1463                        if let Some(def) = &col.default {
1464                            constraints.push(Constraint::Default(def.clone()));
1465                        }
1466                        if let Some(fk) = &col.foreign_key {
1467                            constraints.push(Constraint::References(foreign_key_to_sql(fk)));
1468                        }
1469                        for check in col.checks() {
1470                            let check_sql = check_expr_to_sql(&check.expr);
1471                            if let Some(name) = &check.name {
1472                                constraints.push(Constraint::Check(vec![format!(
1473                                    "CONSTRAINT {} CHECK ({})",
1474                                    name, check_sql
1475                                )]));
1476                            } else {
1477                                constraints.push(Constraint::Check(vec![check_sql]));
1478                            }
1479                        }
1480                        if let Some(generated) = &col.generated {
1481                            constraints.push(generated_to_constraint(generated));
1482                        }
1483                        // SERIAL is a pseudo-type only valid in CREATE TABLE
1484                        // For ALTER TABLE ADD COLUMN, convert to INTEGER/BIGINT
1485                        let data_type = match &col.data_type {
1486                            super::types::ColumnType::Serial => "INTEGER".to_string(),
1487                            super::types::ColumnType::BigSerial => "BIGINT".to_string(),
1488                            other => other.to_pg_type(),
1489                        };
1490
1491                        cmds.push(Qail {
1492                            action: Action::Alter,
1493                            table: name.clone(),
1494                            columns: vec![Expr::Def {
1495                                name: col.name.clone(),
1496                                data_type,
1497                                constraints,
1498                            }],
1499                            ..Default::default()
1500                        });
1501                    }
1502                }
1503            }
1504
1505            // Dropped columns (not handled by hints)
1506            for col in &old_table.columns {
1507                if !new_cols.contains(&col.name) {
1508                    let col_path = format!("{}.{}", name, col.name);
1509                    let is_rename_source = new.migrations.iter().any(
1510                        |h| matches!(h, MigrationHint::Rename { from, .. } if from == &col_path),
1511                    );
1512
1513                    let is_drop_hinted = new.migrations.iter().any(|h| {
1514                        matches!(h, MigrationHint::Drop { target, confirmed: true } if target == &col_path)
1515                    });
1516
1517                    if !is_rename_source && !is_drop_hinted {
1518                        cmds.push(Qail {
1519                            action: Action::AlterDrop,
1520                            table: name.clone(),
1521                            columns: vec![Expr::Named(col.name.clone())],
1522                            ..Default::default()
1523                        });
1524                    }
1525                }
1526            }
1527
1528            // Detect type changes in existing columns
1529            for new_col in &new_table.columns {
1530                if let Some(old_col) = old_table.columns.iter().find(|c| c.name == new_col.name) {
1531                    let new_type = new_col.data_type.to_pg_type();
1532
1533                    if !column_types_equivalent_for_diff(&old_col.data_type, &new_col.data_type) {
1534                        // Type changed - ALTER COLUMN TYPE
1535                        // SERIAL is pseudo-type only valid in CREATE TABLE
1536                        let safe_new_type = match &new_col.data_type {
1537                            super::types::ColumnType::Serial => "INTEGER".to_string(),
1538                            super::types::ColumnType::BigSerial => "BIGINT".to_string(),
1539                            _ => new_type,
1540                        };
1541
1542                        cmds.push(Qail {
1543                            action: Action::AlterType,
1544                            table: name.clone(),
1545                            columns: vec![Expr::Def {
1546                                name: new_col.name.clone(),
1547                                data_type: safe_new_type,
1548                                constraints: vec![],
1549                            }],
1550                            ..Default::default()
1551                        });
1552                    }
1553
1554                    // Detect NOT NULL changes
1555                    if old_col.nullable && !new_col.nullable && !new_col.primary_key {
1556                        // Was nullable, now NOT NULL → SET NOT NULL
1557                        cmds.push(Qail {
1558                            action: Action::AlterSetNotNull,
1559                            table: name.clone(),
1560                            columns: vec![Expr::Named(new_col.name.clone())],
1561                            ..Default::default()
1562                        });
1563                    } else if !old_col.nullable && new_col.nullable && !old_col.primary_key {
1564                        // Was NOT NULL, now nullable → DROP NOT NULL
1565                        cmds.push(Qail {
1566                            action: Action::AlterDropNotNull,
1567                            table: name.clone(),
1568                            columns: vec![Expr::Named(new_col.name.clone())],
1569                            ..Default::default()
1570                        });
1571                    }
1572
1573                    // Detect DEFAULT changes
1574                    match (&old_col.default, &new_col.default) {
1575                        (None, Some(new_default)) => {
1576                            // No default before, now has one → SET DEFAULT
1577                            cmds.push(Qail {
1578                                action: Action::AlterSetDefault,
1579                                table: name.clone(),
1580                                columns: vec![Expr::Named(new_col.name.clone())],
1581                                payload: Some(new_default.clone()),
1582                                ..Default::default()
1583                            });
1584                        }
1585                        (Some(_), None) => {
1586                            // Had default, now removed → DROP DEFAULT
1587                            cmds.push(Qail {
1588                                action: Action::AlterDropDefault,
1589                                table: name.clone(),
1590                                columns: vec![Expr::Named(new_col.name.clone())],
1591                                ..Default::default()
1592                            });
1593                        }
1594                        (Some(old_default), Some(new_default)) if old_default != new_default => {
1595                            // Default value changed → SET DEFAULT (new)
1596                            cmds.push(Qail {
1597                                action: Action::AlterSetDefault,
1598                                table: name.clone(),
1599                                columns: vec![Expr::Named(new_col.name.clone())],
1600                                payload: Some(new_default.clone()),
1601                                ..Default::default()
1602                            });
1603                        }
1604                        _ => {} // Same or both None
1605                    }
1606                }
1607            }
1608
1609            // Detect RLS changes
1610            if !old_table.enable_rls && new_table.enable_rls {
1611                cmds.push(Qail {
1612                    action: Action::AlterEnableRls,
1613                    table: name.clone(),
1614                    ..Default::default()
1615                });
1616            } else if old_table.enable_rls && !new_table.enable_rls {
1617                cmds.push(Qail {
1618                    action: Action::AlterDisableRls,
1619                    table: name.clone(),
1620                    ..Default::default()
1621                });
1622            }
1623
1624            if !old_table.force_rls && new_table.force_rls {
1625                cmds.push(Qail {
1626                    action: Action::AlterForceRls,
1627                    table: name.clone(),
1628                    ..Default::default()
1629                });
1630            } else if old_table.force_rls && !new_table.force_rls {
1631                cmds.push(Qail {
1632                    action: Action::AlterNoForceRls,
1633                    table: name.clone(),
1634                    ..Default::default()
1635                });
1636            }
1637        }
1638    }
1639
1640    // Detect new indexes
1641    for new_idx in &new.indexes {
1642        let exists = old.indexes.iter().any(|i| i.name == new_idx.name);
1643        if !exists {
1644            cmds.push(Qail {
1645                action: Action::Index,
1646                table: String::new(),
1647                index_def: Some(IndexDef {
1648                    name: new_idx.name.clone(),
1649                    table: new_idx.table.clone(),
1650                    columns: if !new_idx.expressions.is_empty() {
1651                        new_idx.expressions.clone()
1652                    } else {
1653                        new_idx.columns.clone()
1654                    },
1655                    unique: new_idx.unique,
1656                    index_type: Some(index_method_str(&new_idx.method).to_string()),
1657                    include: new_idx.include.clone(),
1658                    concurrently: new_idx.concurrently,
1659                    where_clause: new_idx.where_clause.as_ref().map(check_expr_to_sql),
1660                }),
1661                ..Default::default()
1662            });
1663        }
1664    }
1665
1666    let mut fk_table_names: Vec<&String> = new
1667        .tables
1668        .iter()
1669        .filter(|(_, table)| !table.multi_column_fks.is_empty())
1670        .map(|(name, _)| name)
1671        .collect();
1672    fk_table_names.sort();
1673    for name in fk_table_names {
1674        let new_table = &new.tables[name];
1675        if let Some(old_table) = old.tables.get(name) {
1676            for fk in &new_table.multi_column_fks {
1677                if !old_table.multi_column_fks.contains(fk) {
1678                    cmds.push(multi_column_fk_to_alter_command(name, fk));
1679                }
1680            }
1681        } else {
1682            for fk in &new_table.multi_column_fks {
1683                cmds.push(multi_column_fk_to_alter_command(name, fk));
1684            }
1685        }
1686    }
1687
1688    // Detect dropped indexes
1689    for old_idx in &old.indexes {
1690        let exists = new.indexes.iter().any(|i| i.name == old_idx.name);
1691        if !exists {
1692            cmds.push(Qail {
1693                action: Action::DropIndex,
1694                table: old_idx.name.clone(),
1695                ..Default::default()
1696            });
1697        }
1698    }
1699
1700    cmds
1701}
1702
1703/// Parse "table.column" format
1704fn parse_table_col(s: &str) -> Option<(&str, &str)> {
1705    let parts: Vec<&str> = s.splitn(2, '.').collect();
1706    if parts.len() == 2 {
1707        Some((parts[0], parts[1]))
1708    } else {
1709        None
1710    }
1711}
1712
1713#[cfg(test)]
1714mod tests {
1715    use super::super::schema::{
1716        CheckExpr, Column, FkAction, Index, IndexMethod, MultiColumnForeignKey, Table, ViewDef,
1717    };
1718    use super::super::types::ColumnType;
1719    use super::*;
1720
1721    #[test]
1722    fn test_diff_new_table() {
1723        use super::super::types::ColumnType;
1724        let old = Schema::default();
1725        let mut new = Schema::default();
1726        new.add_table(
1727            Table::new("users")
1728                .column(Column::new("id", ColumnType::Serial).primary_key())
1729                .column(Column::new("name", ColumnType::Text).not_null()),
1730        );
1731
1732        let cmds = diff_schemas(&old, &new);
1733        assert_eq!(cmds.len(), 1);
1734        assert!(matches!(cmds[0].action, Action::Make));
1735    }
1736
1737    #[test]
1738    fn state_diff_support_rejects_non_table_object_families() {
1739        let old = Schema::default();
1740        let mut new = Schema::default();
1741        new.add_view(ViewDef::new("active_users", "SELECT 1"));
1742
1743        let err = validate_state_diff_support(&old, &new)
1744            .expect_err("state-based diff should reject unsupported view objects");
1745        assert!(
1746            err.contains("views"),
1747            "error should include unsupported family name"
1748        );
1749    }
1750
1751    #[test]
1752    fn state_diff_checked_passes_for_table_index_only_schema() {
1753        use super::super::types::ColumnType;
1754        let old = Schema::default();
1755        let mut new = Schema::default();
1756        new.add_table(Table::new("users").column(Column::new("id", ColumnType::Serial)));
1757        let cmds = diff_schemas_checked(&old, &new).expect("table/index-only schema should pass");
1758        assert!(
1759            cmds.iter().any(|c| matches!(c.action, Action::Make)),
1760            "checked diff should still produce normal table commands"
1761        );
1762    }
1763
1764    #[test]
1765    fn state_diff_checked_rejects_invalid_target_schema() {
1766        let mut old = Schema::default();
1767        old.add_table(Table::new("users").column(Column::new("id", ColumnType::Int)));
1768
1769        let mut new = old.clone();
1770        new.add_index(Index::new(
1771            "idx_users_missing",
1772            "users",
1773            vec!["missing_col".to_string()],
1774        ));
1775
1776        let err = diff_schemas_checked(&old, &new)
1777            .expect_err("checked diff must reject invalid target schema");
1778        assert!(err.contains("invalid target schema"));
1779        assert!(err.contains("non-existent column 'users.missing_col'"));
1780    }
1781
1782    #[test]
1783    fn state_diff_checked_rejects_unconfirmed_drop_hint() {
1784        let mut old = Schema::default();
1785        old.add_table(Table::new("users").column(Column::new("id", ColumnType::Int)));
1786
1787        let mut new = Schema::default();
1788        new.add_hint(MigrationHint::Drop {
1789            target: "users".to_string(),
1790            confirmed: false,
1791        });
1792
1793        let err =
1794            diff_schemas_checked(&old, &new).expect_err("unconfirmed drop hint should fail closed");
1795        assert!(err.contains("unconfirmed destructive drop hints"));
1796        assert!(err.contains("users"));
1797    }
1798
1799    fn schema_with_users_index(index: Index) -> Schema {
1800        use super::super::types::ColumnType;
1801
1802        let mut schema = Schema::default();
1803        schema.add_table(
1804            Table::new("users")
1805                .column(Column::new("email", ColumnType::Text))
1806                .column(Column::new("username", ColumnType::Text))
1807                .column(Column::new("deleted_at", ColumnType::Text)),
1808        );
1809        schema.add_table(
1810            Table::new("audit_log")
1811                .column(Column::new("impersonation_session_id", ColumnType::Text)),
1812        );
1813        schema.add_table(
1814            Table::new("whatsapp_outbox")
1815                .column(Column::new("next_attempt_at", ColumnType::Timestamp))
1816                .column(Column::new("status", ColumnType::Text)),
1817        );
1818        schema.add_table(
1819            Table::new("car_availability")
1820                .column(Column::new("vehicle_id", ColumnType::Text))
1821                .column(Column::new("service_date", ColumnType::Date))
1822                .column(Column::new("start_time", ColumnType::Time))
1823                .column(Column::new("end_time", ColumnType::Time))
1824                .column(Column::new("status", ColumnType::Text)),
1825        );
1826        schema.add_index(index);
1827        schema
1828    }
1829
1830    #[test]
1831    fn state_diff_checked_rejects_same_name_index_unique_change() {
1832        let old = schema_with_users_index(Index::new(
1833            "idx_users_email",
1834            "users",
1835            vec!["email".to_string()],
1836        ));
1837        let new = schema_with_users_index(
1838            Index::new("idx_users_email", "users", vec!["email".to_string()]).unique(),
1839        );
1840
1841        let err = diff_schemas_checked(&old, &new)
1842            .expect_err("same-name index unique change should fail closed");
1843        assert!(err.contains("replace existing indexes"));
1844        assert!(err.contains("idx_users_email"));
1845    }
1846
1847    #[test]
1848    fn state_diff_checked_rejects_same_name_index_predicate_change() {
1849        let old = schema_with_users_index(
1850            Index::new("idx_users_email", "users", vec!["email".to_string()])
1851                .partial(CheckExpr::Sql("deleted_at IS NULL".to_string())),
1852        );
1853        let new = schema_with_users_index(
1854            Index::new("idx_users_email", "users", vec!["email".to_string()])
1855                .partial(CheckExpr::Sql("deleted_at IS NOT NULL".to_string())),
1856        );
1857
1858        let err = diff_schemas_checked(&old, &new)
1859            .expect_err("same-name index predicate change should fail closed");
1860        assert!(err.contains("replace existing indexes"));
1861        assert!(err.contains("idx_users_email"));
1862        assert!(err.contains("where:"));
1863    }
1864
1865    #[test]
1866    fn state_diff_checked_rejects_same_name_index_method_change() {
1867        let old = schema_with_users_index(Index::new(
1868            "idx_users_email",
1869            "users",
1870            vec!["email".to_string()],
1871        ));
1872        let new = schema_with_users_index(
1873            Index::new("idx_users_email", "users", vec!["email".to_string()])
1874                .using(IndexMethod::Hash),
1875        );
1876
1877        let err = diff_schemas_checked(&old, &new)
1878            .expect_err("same-name index method change should fail closed");
1879        assert!(err.contains("replace existing indexes"));
1880        assert!(err.contains("idx_users_email"));
1881    }
1882
1883    #[test]
1884    fn state_diff_checked_rejects_same_name_index_column_change() {
1885        let old = schema_with_users_index(Index::new(
1886            "idx_users_email",
1887            "users",
1888            vec!["email".to_string()],
1889        ));
1890        let new = schema_with_users_index(Index::new(
1891            "idx_users_email",
1892            "users",
1893            vec!["username".to_string()],
1894        ));
1895
1896        let err = diff_schemas_checked(&old, &new)
1897            .expect_err("same-name index column change should fail closed");
1898        assert!(err.contains("replace existing indexes"));
1899        assert!(err.contains("idx_users_email"));
1900    }
1901
1902    #[test]
1903    fn state_diff_index_compare_ignores_concurrently_execution_option() {
1904        let old = schema_with_users_index(Index::new(
1905            "idx_users_email",
1906            "users",
1907            vec!["email".to_string()],
1908        ));
1909        let new = schema_with_users_index(
1910            Index::new("idx_users_email", "users", vec!["email".to_string()]).concurrently(),
1911        );
1912
1913        let cmds = diff_schemas_checked(&old, &new)
1914            .expect("CONCURRENTLY is an execution option, not index definition drift");
1915        assert!(cmds.is_empty());
1916    }
1917
1918    #[test]
1919    fn state_diff_index_compare_ignores_postgres_predicate_parentheses() {
1920        let old = schema_with_users_index(
1921            Index::new(
1922                "audit_log_session",
1923                "audit_log",
1924                vec!["impersonation_session_id".to_string()],
1925            )
1926            .partial(CheckExpr::Sql(
1927                "(impersonation_session_id IS NOT NULL)".to_string(),
1928            )),
1929        );
1930        let new = schema_with_users_index(
1931            Index::new(
1932                "audit_log_session",
1933                "audit_log",
1934                vec!["impersonation_session_id".to_string()],
1935            )
1936            .partial(CheckExpr::Sql(
1937                "impersonation_session_id IS NOT NULL".to_string(),
1938            )),
1939        );
1940
1941        let cmds = diff_schemas_checked(&old, &new)
1942            .expect("equivalent partial index predicates should not fail closed");
1943        assert!(cmds.is_empty());
1944    }
1945
1946    #[test]
1947    fn state_diff_index_compare_ignores_postgres_text_casts() {
1948        let old = schema_with_users_index(
1949            Index::expression(
1950                "users_email_unique_ci",
1951                "users",
1952                vec!["lower((email)::text)".to_string()],
1953            )
1954            .unique(),
1955        );
1956        let new = schema_with_users_index(
1957            Index::expression(
1958                "users_email_unique_ci",
1959                "users",
1960                vec!["lower(email)".to_string()],
1961            )
1962            .unique(),
1963        );
1964
1965        let cmds = diff_schemas_checked(&old, &new)
1966            .expect("equivalent expression index casts should not fail closed");
1967        assert!(cmds.is_empty());
1968    }
1969
1970    #[test]
1971    fn state_diff_index_compare_ignores_in_any_array_canonicalization() {
1972        let old = schema_with_users_index(
1973            Index::new(
1974                "idx_outbox_due",
1975                "whatsapp_outbox",
1976                vec!["next_attempt_at".to_string()],
1977            )
1978            .partial(CheckExpr::Sql(
1979                "status = ANY (ARRAY['pending'::text, 'failed'::text])".to_string(),
1980            )),
1981        );
1982        let new = schema_with_users_index(
1983            Index::new(
1984                "idx_outbox_due",
1985                "whatsapp_outbox",
1986                vec!["next_attempt_at".to_string()],
1987            )
1988            .partial(CheckExpr::Sql(
1989                "status IN ('pending', 'failed')".to_string(),
1990            )),
1991        );
1992
1993        let cmds = diff_schemas_checked(&old, &new)
1994            .expect("equivalent IN predicate forms should not fail closed");
1995        assert!(cmds.is_empty());
1996    }
1997
1998    #[test]
1999    fn state_diff_index_compare_ignores_not_equal_canonicalization() {
2000        let old = schema_with_users_index(
2001            Index::new(
2002                "idx_car_availability_overlap",
2003                "car_availability",
2004                vec![
2005                    "vehicle_id".to_string(),
2006                    "service_date".to_string(),
2007                    "start_time".to_string(),
2008                    "end_time".to_string(),
2009                ],
2010            )
2011            .partial(CheckExpr::Sql(
2012                "((status)::text <> 'completed'::text)".to_string(),
2013            )),
2014        );
2015        let new = schema_with_users_index(
2016            Index::new(
2017                "idx_car_availability_overlap",
2018                "car_availability",
2019                vec![
2020                    "vehicle_id".to_string(),
2021                    "service_date".to_string(),
2022                    "start_time".to_string(),
2023                    "end_time".to_string(),
2024                ],
2025            )
2026            .partial(CheckExpr::Sql("status != 'completed'".to_string())),
2027        );
2028
2029        let cmds = diff_schemas_checked(&old, &new)
2030            .expect("equivalent not-equal predicates should not fail closed");
2031        assert!(cmds.is_empty());
2032    }
2033
2034    #[test]
2035    fn state_diff_checked_rejects_existing_column_check_addition() {
2036        use super::super::types::ColumnType;
2037
2038        let mut old = Schema::default();
2039        old.add_table(
2040            Table::new("inventory").column(Column::new("quantity", ColumnType::Int).not_null()),
2041        );
2042
2043        let mut new = Schema::default();
2044        new.add_table(
2045            Table::new("inventory").column(
2046                Column::new("quantity", ColumnType::Int).not_null().check(
2047                    CheckExpr::GreaterOrEqual {
2048                        column: "quantity".to_string(),
2049                        value: 0,
2050                    },
2051                ),
2052            ),
2053        );
2054
2055        let err = diff_schemas_checked(&old, &new)
2056            .expect_err("existing-column CHECK change should fail closed");
2057        assert!(err.contains("CHECK constraints"));
2058        assert!(err.contains("inventory.quantity"));
2059    }
2060
2061    #[test]
2062    fn state_diff_check_compare_is_table_scoped_not_column_anchor_scoped() {
2063        use super::super::types::ColumnType;
2064
2065        let check = CheckExpr::Sql(
2066            "((segment_id IS NOT NULL) AND (virtual_segment_id IS NULL)) OR ((segment_id IS NULL) AND (virtual_segment_id IS NOT NULL))"
2067                .to_string(),
2068        );
2069
2070        let mut old = Schema::default();
2071        old.add_table(
2072            Table::new("pricing_plans")
2073                .column(
2074                    Column::new("segment_id", ColumnType::Uuid)
2075                        .check_named("pricing_plans_single_source_of_truth", check.clone()),
2076                )
2077                .column(Column::new("virtual_segment_id", ColumnType::Uuid)),
2078        );
2079
2080        let mut new = Schema::default();
2081        new.add_table(
2082            Table::new("pricing_plans")
2083                .column(Column::new("segment_id", ColumnType::Uuid))
2084                .column(
2085                    Column::new("virtual_segment_id", ColumnType::Uuid)
2086                        .check_named("pricing_plans_single_source_of_truth", check),
2087                ),
2088        );
2089
2090        let cmds = diff_schemas_checked(&old, &new)
2091            .expect("same table-level CHECK should not depend on inline column anchor");
2092        assert!(cmds.is_empty());
2093    }
2094
2095    #[test]
2096    fn state_diff_check_compare_normalizes_sql_and_ast_equivalent_checks() {
2097        let mut old = Schema::default();
2098        old.add_table(Table::new("inventory").column(
2099            Column::new("quantity", ColumnType::Int).check_named(
2100                "inventory_quantity_check",
2101                CheckExpr::Sql("((quantity >= 0))".to_string()),
2102            ),
2103        ));
2104
2105        let mut new = Schema::default();
2106        new.add_table(Table::new("inventory").column(
2107            Column::new("quantity", ColumnType::Int).check(CheckExpr::GreaterOrEqual {
2108                column: "quantity".to_string(),
2109                value: 0,
2110            }),
2111        ));
2112
2113        let cmds = diff_schemas_checked(&old, &new)
2114            .expect("equivalent SQL and AST-native CHECK predicates should not fail closed");
2115        assert!(cmds.is_empty());
2116    }
2117
2118    #[test]
2119    fn state_diff_check_compare_ignores_lowercase_identifier_quotes() {
2120        let mut old = Schema::default();
2121        old.add_table(
2122            Table::new("schedule_patterns").column(
2123                Column::new("interval", ColumnType::Int)
2124                    .check(CheckExpr::Sql("\"interval\" > 0".to_string())),
2125            ),
2126        );
2127
2128        let mut new = Schema::default();
2129        new.add_table(Table::new("schedule_patterns").column(
2130            Column::new("interval", ColumnType::Int).check(CheckExpr::GreaterThan {
2131                column: "interval".to_string(),
2132                value: 0,
2133            }),
2134        ));
2135
2136        let cmds = diff_schemas_checked(&old, &new)
2137            .expect("quoted lowercase identifier should match unquoted column reference");
2138        assert!(cmds.is_empty());
2139    }
2140
2141    #[test]
2142    fn state_diff_check_error_reports_directional_signatures() {
2143        let mut old = Schema::default();
2144        old.add_table(
2145            Table::new("charters_diathesi")
2146                .column(
2147                    Column::new("current_bookings", ColumnType::Int).check(CheckExpr::Sql(
2148                        "current_bookings <= max_bookings".to_string(),
2149                    )),
2150                )
2151                .column(Column::new("max_bookings", ColumnType::Int)),
2152        );
2153
2154        let mut new = Schema::default();
2155        new.add_table(
2156            Table::new("charters_diathesi")
2157                .column(Column::new("current_bookings", ColumnType::Int))
2158                .column(Column::new("max_bookings", ColumnType::Int)),
2159        );
2160
2161        let err = diff_schemas_checked(&old, &new)
2162            .expect_err("missing target CHECK should fail closed with details");
2163        assert!(err.contains("charters_diathesi.current_bookings"), "{err}");
2164        assert!(err.contains("old CHECK not present in new schema"), "{err}");
2165        assert!(err.contains("current_bookings<=max_bookings"), "{err}");
2166    }
2167
2168    #[test]
2169    fn state_diff_preserves_multiple_checks_on_one_column() {
2170        use super::super::schema::CheckComparisonOp;
2171
2172        let column = Column::new("module", ColumnType::Text)
2173            .check_named(
2174                "tenant_modules_no_legacy_fastboat",
2175                CheckExpr::TextCompare {
2176                    column: "module".to_string(),
2177                    op: CheckComparisonOp::NotEqual,
2178                    value: "fastboat".to_string(),
2179                },
2180            )
2181            .additional_check_named(
2182                "tenant_modules_no_legacy_charter",
2183                CheckExpr::TextCompare {
2184                    column: "module".to_string(),
2185                    op: CheckComparisonOp::NotEqual,
2186                    value: "charter".to_string(),
2187                },
2188            );
2189
2190        let mut old = Schema::default();
2191        old.add_table(Table::new("tenant_modules").column(column.clone()));
2192
2193        let mut new = Schema::default();
2194        new.add_table(Table::new("tenant_modules").column(column));
2195
2196        let cmds = diff_schemas_checked(&old, &new)
2197            .expect("matching multi-check columns should not drift");
2198        assert!(cmds.is_empty());
2199    }
2200
2201    #[test]
2202    fn state_diff_checked_rejects_existing_column_unique_addition() {
2203        use super::super::types::ColumnType;
2204
2205        let mut old = Schema::default();
2206        old.add_table(
2207            Table::new("users").column(Column::new("email", ColumnType::Text).not_null()),
2208        );
2209
2210        let mut new = Schema::default();
2211        new.add_table(
2212            Table::new("users").column(Column::new("email", ColumnType::Text).not_null().unique()),
2213        );
2214
2215        let err = diff_schemas_checked(&old, &new)
2216            .expect_err("existing-column UNIQUE change should fail closed");
2217        assert!(err.contains("UNIQUE constraints"));
2218        assert!(err.contains("users.email"));
2219    }
2220
2221    #[test]
2222    fn state_diff_checked_rejects_existing_column_primary_key_addition() {
2223        use super::super::types::ColumnType;
2224
2225        let mut old = Schema::default();
2226        old.add_table(Table::new("api_keys").column(Column::new("key", ColumnType::Text)));
2227
2228        let mut new = Schema::default();
2229        new.add_table(
2230            Table::new("api_keys").column(Column::new("key", ColumnType::Text).primary_key()),
2231        );
2232
2233        let err = diff_schemas_checked(&old, &new)
2234            .expect_err("existing-column PRIMARY KEY addition should fail closed");
2235        assert!(err.contains("PRIMARY KEY constraints"));
2236        assert!(err.contains("api_keys.key"));
2237    }
2238
2239    #[test]
2240    fn state_diff_checked_rejects_existing_column_primary_key_removal() {
2241        use super::super::types::ColumnType;
2242
2243        let mut old = Schema::default();
2244        old.add_table(
2245            Table::new("api_keys").column(Column::new("key", ColumnType::Text).primary_key()),
2246        );
2247
2248        let mut new = Schema::default();
2249        new.add_table(Table::new("api_keys").column(Column::new("key", ColumnType::Text)));
2250
2251        let err = diff_schemas_checked(&old, &new)
2252            .expect_err("existing-column PRIMARY KEY removal should fail closed");
2253        assert!(err.contains("PRIMARY KEY constraints"));
2254        assert!(err.contains("api_keys.key"));
2255    }
2256
2257    #[test]
2258    fn state_diff_checked_rejects_existing_column_set_not_null() {
2259        let mut old = Schema::default();
2260        old.add_table(Table::new("users").column(Column::new("email", ColumnType::Text)));
2261
2262        let mut new = Schema::default();
2263        new.add_table(
2264            Table::new("users").column(Column::new("email", ColumnType::Text).not_null()),
2265        );
2266
2267        let err = diff_schemas_checked(&old, &new)
2268            .expect_err("SET NOT NULL should require explicit backfill/validation");
2269        assert!(err.contains("set NOT NULL"));
2270        assert!(err.contains("users.email"));
2271    }
2272
2273    #[test]
2274    fn state_diff_checked_rejects_new_primary_key_column_on_existing_table() {
2275        let mut old = Schema::default();
2276        old.add_table(Table::new("api_keys").column(Column::new("label", ColumnType::Text)));
2277
2278        let mut new = old.clone();
2279        new.tables
2280            .get_mut("api_keys")
2281            .expect("api_keys table should exist")
2282            .columns
2283            .push(Column::new("key", ColumnType::Text).primary_key());
2284
2285        let err = diff_schemas_checked(&old, &new)
2286            .expect_err("new PRIMARY KEY column on existing table should fail closed");
2287        assert!(err.contains("add PRIMARY KEY columns"));
2288        assert!(err.contains("api_keys.key"));
2289    }
2290
2291    #[test]
2292    fn state_diff_checked_rejects_new_required_column_without_value_source() {
2293        let mut old = Schema::default();
2294        old.add_table(Table::new("users").column(Column::new("id", ColumnType::Int)));
2295
2296        let mut new = old.clone();
2297        new.tables
2298            .get_mut("users")
2299            .expect("users table should exist")
2300            .columns
2301            .push(Column::new("email", ColumnType::Text).not_null());
2302
2303        let err = diff_schemas_checked(&old, &new)
2304            .expect_err("required column without default should require explicit migration");
2305        assert!(err.contains("required columns"));
2306        assert!(err.contains("users.email"));
2307    }
2308
2309    #[test]
2310    fn state_diff_checked_allows_new_required_column_with_default() {
2311        use crate::transpiler::ToSql;
2312
2313        let mut old = Schema::default();
2314        old.add_table(Table::new("users").column(Column::new("id", ColumnType::Int)));
2315
2316        let mut new = old.clone();
2317        new.tables
2318            .get_mut("users")
2319            .expect("users table should exist")
2320            .columns
2321            .push(
2322                Column::new("status", ColumnType::Text)
2323                    .not_null()
2324                    .default("'active'"),
2325            );
2326
2327        let cmds = diff_schemas_checked(&old, &new)
2328            .expect("required column with default should be auto-planned");
2329        let add_col = cmds
2330            .iter()
2331            .find(|cmd| cmd.action == Action::Alter && cmd.table == "users")
2332            .expect("add-column command should be present");
2333
2334        let sql = add_col.to_sql();
2335        assert!(
2336            sql.contains("ADD COLUMN status TEXT NOT NULL DEFAULT 'active'"),
2337            "add-column SQL should preserve default-backed NOT NULL, got: {sql}"
2338        );
2339    }
2340
2341    #[test]
2342    fn state_diff_checked_rejects_new_unique_column_with_default() {
2343        let mut old = Schema::default();
2344        old.add_table(Table::new("users").column(Column::new("id", ColumnType::Int)));
2345
2346        let mut new = old.clone();
2347        new.tables
2348            .get_mut("users")
2349            .expect("users table should exist")
2350            .columns
2351            .push(
2352                Column::new("external_id", ColumnType::Text)
2353                    .unique()
2354                    .default("'same-for-existing-rows'"),
2355            );
2356
2357        let err = diff_schemas_checked(&old, &new)
2358            .expect_err("UNIQUE column with default can duplicate existing rows");
2359        assert!(err.contains("UNIQUE columns"));
2360        assert!(err.contains("users.external_id"));
2361    }
2362
2363    #[test]
2364    fn state_diff_checked_rejects_new_foreign_key_column_with_default() {
2365        let mut old = Schema::default();
2366        old.add_table(
2367            Table::new("tenants").column(Column::new("id", ColumnType::Uuid).primary_key()),
2368        );
2369        old.add_table(Table::new("orders").column(Column::new("id", ColumnType::Uuid)));
2370
2371        let mut new = old.clone();
2372        new.tables
2373            .get_mut("orders")
2374            .expect("orders table should exist")
2375            .columns
2376            .push(
2377                Column::new("tenant_id", ColumnType::Uuid)
2378                    .references("tenants", "id")
2379                    .default("'00000000-0000-0000-0000-000000000000'::uuid"),
2380            );
2381
2382        let err = diff_schemas_checked(&old, &new)
2383            .expect_err("FK column with default can violate existing references");
2384        assert!(err.contains("FOREIGN KEY columns"));
2385        assert!(err.contains("orders.tenant_id"));
2386    }
2387
2388    #[test]
2389    fn state_diff_checked_rejects_new_column_with_raw_sql_check() {
2390        let mut old = Schema::default();
2391        old.add_table(Table::new("users").column(Column::new("id", ColumnType::Int)));
2392
2393        let mut new = old.clone();
2394        new.tables
2395            .get_mut("users")
2396            .expect("users table should exist")
2397            .columns
2398            .push(
2399                Column::new("email", ColumnType::Text)
2400                    .check(CheckExpr::Sql("email IS NOT NULL".to_string())),
2401            );
2402
2403        let err =
2404            diff_schemas_checked(&old, &new).expect_err("raw SQL CHECK may validate existing rows");
2405        assert!(err.contains("CHECK constraints"));
2406        assert!(err.contains("users.email"));
2407    }
2408
2409    #[test]
2410    fn state_diff_checked_rejects_new_column_with_check_and_default() {
2411        let mut old = Schema::default();
2412        old.add_table(Table::new("inventory").column(Column::new("id", ColumnType::Int)));
2413
2414        let mut new = old.clone();
2415        new.tables
2416            .get_mut("inventory")
2417            .expect("inventory table should exist")
2418            .columns
2419            .push(
2420                Column::new("quantity", ColumnType::Int)
2421                    .default("-1")
2422                    .check(CheckExpr::GreaterOrEqual {
2423                        column: "quantity".to_string(),
2424                        value: 0,
2425                    }),
2426            );
2427
2428        let err = diff_schemas_checked(&old, &new)
2429            .expect_err("CHECK column with default can validate existing rows");
2430        assert!(err.contains("CHECK constraints"));
2431        assert!(err.contains("inventory.quantity"));
2432    }
2433
2434    #[test]
2435    fn state_diff_checked_rejects_new_serial_pseudo_type_column_on_existing_table() {
2436        let mut old = Schema::default();
2437        old.add_table(Table::new("events").column(Column::new("name", ColumnType::Text)));
2438
2439        let mut new = old.clone();
2440        new.tables
2441            .get_mut("events")
2442            .expect("events table should exist")
2443            .columns
2444            .push(Column::new("id", ColumnType::Serial));
2445
2446        let err = diff_schemas_checked(&old, &new)
2447            .expect_err("SERIAL add-column cannot be represented by ALTER ADD COLUMN INTEGER");
2448        assert!(err.contains("SERIAL/BIGSERIAL"));
2449        assert!(err.contains("events.id"));
2450    }
2451
2452    #[test]
2453    fn state_diff_checked_rejects_unsafe_existing_column_type_change() {
2454        let mut old = Schema::default();
2455        old.add_table(Table::new("events").column(Column::new("external_id", ColumnType::Text)));
2456
2457        let mut new = Schema::default();
2458        new.add_table(Table::new("events").column(Column::new("external_id", ColumnType::Uuid)));
2459
2460        let err = diff_schemas_checked(&old, &new)
2461            .expect_err("TEXT -> UUID should require an explicit cast plan");
2462        assert!(err.contains("existing column types"));
2463        assert!(err.contains("events.external_id"));
2464        assert!(err.contains("TEXT -> UUID"));
2465    }
2466
2467    #[test]
2468    fn state_diff_checked_does_not_treat_array_default_as_type_suffix() {
2469        let old = super::super::parser::parse_qail(
2470            r#"
2471table agents {
2472  id uuid primary_key
2473  verticals TEXT[]
2474}
2475"#,
2476        )
2477        .expect("old schema should parse");
2478        let new = super::super::parser::parse_qail(
2479            r#"
2480table agents {
2481  id uuid primary_key
2482  verticals TEXT[] not_null default '{}'::text[]
2483}
2484"#,
2485        )
2486        .expect("new schema should parse");
2487
2488        let err = diff_schemas_checked(&old, &new)
2489            .expect_err("setting NOT NULL on an existing array column needs explicit migration");
2490        assert!(err.contains("set NOT NULL"));
2491        assert!(err.contains("agents.verticals"));
2492        assert!(!err.contains("existing column types"));
2493        assert!(!err.contains("TEXT[] NOT_NULL DEFAULT"));
2494    }
2495
2496    #[test]
2497    fn state_diff_checked_ignores_unquoted_enum_identifier_case_drift() {
2498        let mut old = Schema::default();
2499        old.add_table(Table::new("articles").column(Column::new(
2500            "status",
2501            ColumnType::Range("ARTICLE_STATUS".to_string()),
2502        )));
2503
2504        let mut new = Schema::default();
2505        new.add_table(Table::new("articles").column(Column::new(
2506            "status",
2507            ColumnType::Enum {
2508                name: "article_status".to_string(),
2509                values: vec![
2510                    "draft".to_string(),
2511                    "published".to_string(),
2512                    "archived".to_string(),
2513                ],
2514            },
2515        )));
2516
2517        let cmds = diff_schemas_checked(&old, &new)
2518            .expect("case-only enum placeholder drift should be treated as same type");
2519        assert!(
2520            cmds.iter()
2521                .all(|cmd| cmd.action != Action::AlterType || cmd.table != "articles"),
2522            "case-only enum type drift must not emit ALTER TYPE: {cmds:?}"
2523        );
2524    }
2525
2526    #[test]
2527    fn state_diff_checked_ignores_array_enum_identifier_case_drift() {
2528        let mut old = Schema::default();
2529        old.add_table(Table::new("operators").column(Column::new(
2530            "roles",
2531            ColumnType::Array(Box::new(ColumnType::Range("USER_ROLE".to_string()))),
2532        )));
2533
2534        let mut new = Schema::default();
2535        new.add_table(Table::new("operators").column(Column::new(
2536            "roles",
2537            ColumnType::Array(Box::new(ColumnType::Enum {
2538                name: "user_role".to_string(),
2539                values: vec!["admin".to_string(), "operator".to_string()],
2540            })),
2541        )));
2542
2543        let cmds = diff_schemas_checked(&old, &new)
2544            .expect("case-only array enum placeholder drift should be treated as same type");
2545        assert!(
2546            cmds.iter()
2547                .all(|cmd| cmd.action != Action::AlterType || cmd.table != "operators"),
2548            "case-only array enum type drift must not emit ALTER TYPE: {cmds:?}"
2549        );
2550    }
2551
2552    #[test]
2553    fn state_diff_checked_rejects_distinct_custom_type_names() {
2554        let mut old = Schema::default();
2555        old.add_table(Table::new("articles").column(Column::new(
2556            "status",
2557            ColumnType::Range("article_status".to_string()),
2558        )));
2559
2560        let mut new = Schema::default();
2561        new.add_table(Table::new("articles").column(Column::new(
2562            "status",
2563            ColumnType::Enum {
2564                name: "user_role".to_string(),
2565                values: vec!["admin".to_string(), "operator".to_string()],
2566            },
2567        )));
2568
2569        let err = diff_schemas_checked(&old, &new)
2570            .expect_err("distinct custom type names should require explicit migration");
2571        assert!(err.contains("existing column types"));
2572        assert!(err.contains("articles.status"));
2573    }
2574
2575    #[test]
2576    fn state_diff_checked_rejects_existing_column_serial_pseudo_type_change() {
2577        let mut old = Schema::default();
2578        old.add_table(Table::new("events").column(Column::new("id", ColumnType::Int)));
2579
2580        let mut new = Schema::default();
2581        new.add_table(Table::new("events").column(Column::new("id", ColumnType::Serial)));
2582
2583        let err = diff_schemas_checked(&old, &new)
2584            .expect_err("INT -> SERIAL cannot be represented by ALTER COLUMN TYPE");
2585        assert!(err.contains("existing column types"));
2586        assert!(err.contains("events.id"));
2587        assert!(err.contains("INT -> SERIAL"));
2588    }
2589
2590    #[test]
2591    fn state_diff_checked_allows_safe_existing_column_type_widening() {
2592        use crate::transpiler::ToSql;
2593
2594        let mut old = Schema::default();
2595        old.add_table(Table::new("events").column(Column::new("counter", ColumnType::Int)));
2596
2597        let mut new = Schema::default();
2598        new.add_table(Table::new("events").column(Column::new("counter", ColumnType::BigInt)));
2599
2600        let cmds = diff_schemas_checked(&old, &new).expect("INT -> BIGINT should be auto-planned");
2601        let type_cmd = cmds
2602            .iter()
2603            .find(|cmd| cmd.action == Action::AlterType && cmd.table == "events")
2604            .expect("ALTER TYPE command should be present");
2605
2606        assert_eq!(
2607            type_cmd.to_sql(),
2608            "ALTER TABLE events ALTER COLUMN counter TYPE BIGINT"
2609        );
2610    }
2611
2612    #[test]
2613    fn state_diff_checked_allows_safe_decimal_typmod_widening() {
2614        use crate::transpiler::ToSql;
2615
2616        let mut old = Schema::default();
2617        old.add_table(
2618            Table::new("prices").column(Column::new("amount", ColumnType::Decimal(Some((12, 6))))),
2619        );
2620
2621        let mut new = Schema::default();
2622        new.add_table(
2623            Table::new("prices").column(Column::new("amount", ColumnType::Decimal(None))),
2624        );
2625
2626        let cmds =
2627            diff_schemas_checked(&old, &new).expect("DECIMAL(p,s) -> DECIMAL should be safe");
2628        let type_cmd = cmds
2629            .iter()
2630            .find(|cmd| cmd.action == Action::AlterType && cmd.table == "prices")
2631            .expect("ALTER TYPE command should be present");
2632
2633        assert_eq!(
2634            type_cmd.to_sql(),
2635            "ALTER TABLE prices ALTER COLUMN amount TYPE DECIMAL"
2636        );
2637    }
2638
2639    #[test]
2640    fn state_diff_checked_rejects_decimal_typmod_narrowing() {
2641        let mut old = Schema::default();
2642        old.add_table(
2643            Table::new("prices").column(Column::new("amount", ColumnType::Decimal(None))),
2644        );
2645
2646        let mut new = Schema::default();
2647        new.add_table(
2648            Table::new("prices").column(Column::new("amount", ColumnType::Decimal(Some((12, 6))))),
2649        );
2650
2651        let err = diff_schemas_checked(&old, &new)
2652            .expect_err("DECIMAL -> DECIMAL(p,s) needs explicit validation");
2653        assert!(err.contains("existing column types"));
2654        assert!(err.contains("prices.amount"));
2655    }
2656
2657    #[test]
2658    fn state_diff_checked_rejects_varchar_length_narrowing() {
2659        let mut old = Schema::default();
2660        old.add_table(
2661            Table::new("users").column(Column::new("display_name", ColumnType::Varchar(Some(255)))),
2662        );
2663
2664        let mut new = Schema::default();
2665        new.add_table(
2666            Table::new("users").column(Column::new("display_name", ColumnType::Varchar(Some(64)))),
2667        );
2668
2669        let err = diff_schemas_checked(&old, &new)
2670            .expect_err("VARCHAR length shrink should require explicit validation");
2671        assert!(err.contains("existing column types"));
2672        assert!(err.contains("users.display_name"));
2673    }
2674
2675    #[test]
2676    fn state_diff_checked_rejects_existing_column_foreign_key_addition() {
2677        let mut old = Schema::default();
2678        old.add_table(
2679            Table::new("tenants").column(Column::new("id", ColumnType::Int).primary_key()),
2680        );
2681        old.add_table(Table::new("orders").column(Column::new("tenant_id", ColumnType::Int)));
2682
2683        let mut new = Schema::default();
2684        new.add_table(
2685            Table::new("tenants").column(Column::new("id", ColumnType::Int).primary_key()),
2686        );
2687        new.add_table(
2688            Table::new("orders")
2689                .column(Column::new("tenant_id", ColumnType::Int).references("tenants", "id")),
2690        );
2691
2692        let err = diff_schemas_checked(&old, &new)
2693            .expect_err("existing-column single-column FK change should fail closed");
2694        assert!(err.contains("single-column foreign keys"));
2695        assert!(err.contains("orders.tenant_id"));
2696    }
2697
2698    #[test]
2699    fn diff_new_column_preserves_foreign_key_reference() {
2700        use super::super::types::ColumnType;
2701        use crate::transpiler::ToSql;
2702
2703        let mut old = Schema::default();
2704        old.add_table(
2705            Table::new("tenants").column(Column::new("id", ColumnType::Int).primary_key()),
2706        );
2707        old.add_table(Table::new("orders").column(Column::new("id", ColumnType::Int)));
2708
2709        let mut new = old.clone();
2710        new.tables
2711            .get_mut("orders")
2712            .expect("orders table should exist")
2713            .columns
2714            .push(
2715                Column::new("tenant_id", ColumnType::Int)
2716                    .references("tenants", "id")
2717                    .on_delete(FkAction::Cascade)
2718                    .on_update(FkAction::Restrict)
2719                    .initially_deferred(),
2720            );
2721
2722        let cmds = diff_schemas_checked(&old, &new).expect("new referenced column should diff");
2723        let add_col = cmds
2724            .iter()
2725            .find(|cmd| matches!(cmd.action, Action::Alter) && cmd.table == "orders")
2726            .expect("add-column command should be present");
2727
2728        let Expr::Def { constraints, .. } = &add_col.columns[0] else {
2729            panic!("expected added column def");
2730        };
2731        assert!(constraints.iter().any(|constraint| {
2732            matches!(
2733                constraint,
2734                Constraint::References(target)
2735                    if target == "tenants(id) ON DELETE CASCADE ON UPDATE RESTRICT DEFERRABLE INITIALLY DEFERRED"
2736            )
2737        }));
2738
2739        let sql = add_col.to_sql();
2740        assert!(
2741            sql.contains(
2742                "REFERENCES tenants(id) ON DELETE CASCADE ON UPDATE RESTRICT DEFERRABLE INITIALLY DEFERRED"
2743            ),
2744            "add-column SQL should preserve FK reference, got: {sql}"
2745        );
2746    }
2747
2748    #[test]
2749    fn diff_new_column_preserves_check_constraint() {
2750        use super::super::types::ColumnType;
2751        use crate::transpiler::ToSql;
2752
2753        let mut old = Schema::default();
2754        old.add_table(Table::new("players").column(Column::new("id", ColumnType::Int)));
2755
2756        let mut new = old.clone();
2757        new.tables
2758            .get_mut("players")
2759            .expect("players table should exist")
2760            .columns
2761            .push(
2762                Column::new("score", ColumnType::Int).check(CheckExpr::GreaterOrEqual {
2763                    column: "score".to_string(),
2764                    value: 0,
2765                }),
2766            );
2767
2768        let cmds = diff_schemas_checked(&old, &new).expect("new checked column should diff");
2769        let add_col = cmds
2770            .iter()
2771            .find(|cmd| matches!(cmd.action, Action::Alter) && cmd.table == "players")
2772            .expect("add-column command should be present");
2773
2774        let Expr::Def { constraints, .. } = &add_col.columns[0] else {
2775            panic!("expected score column definition");
2776        };
2777        assert!(constraints.iter().any(|constraint| {
2778            matches!(
2779                constraint,
2780                Constraint::Check(vals) if vals.len() == 1 && vals[0] == "score >= 0"
2781            )
2782        }));
2783
2784        let sql = add_col.to_sql();
2785        assert!(
2786            sql.contains("CHECK (score >= 0)"),
2787            "add-column SQL should preserve CHECK constraint, got: {sql}"
2788        );
2789    }
2790
2791    #[test]
2792    fn diff_new_column_preserves_unique_constraint() {
2793        use super::super::types::ColumnType;
2794        use crate::transpiler::ToSql;
2795
2796        let mut old = Schema::default();
2797        old.add_table(Table::new("users").column(Column::new("id", ColumnType::Int)));
2798
2799        let mut new = old.clone();
2800        new.tables
2801            .get_mut("users")
2802            .expect("users table should exist")
2803            .columns
2804            .push(Column::new("email", ColumnType::Text).unique());
2805
2806        let cmds = diff_schemas_checked(&old, &new).expect("new unique column should diff");
2807        let add_col = cmds
2808            .iter()
2809            .find(|cmd| matches!(cmd.action, Action::Alter) && cmd.table == "users")
2810            .expect("add-column command should be present");
2811
2812        let Expr::Def { constraints, .. } = &add_col.columns[0] else {
2813            panic!("expected email column definition");
2814        };
2815        assert!(constraints.contains(&Constraint::Unique));
2816
2817        let sql = add_col.to_sql();
2818        assert!(
2819            sql.contains("UNIQUE"),
2820            "add-column SQL should preserve UNIQUE constraint, got: {sql}"
2821        );
2822    }
2823
2824    #[test]
2825    fn diff_new_column_preserves_generated_constraint() {
2826        use super::super::types::ColumnType;
2827        use crate::transpiler::ToSql;
2828
2829        let mut old = Schema::default();
2830        old.add_table(
2831            Table::new("people")
2832                .column(Column::new("first_name", ColumnType::Text))
2833                .column(Column::new("last_name", ColumnType::Text)),
2834        );
2835
2836        let mut new = old.clone();
2837        new.tables
2838            .get_mut("people")
2839            .expect("people table should exist")
2840            .columns
2841            .push(
2842                Column::new("full_name", ColumnType::Text)
2843                    .generated_stored("first_name || ' ' || last_name"),
2844            );
2845
2846        let cmds = diff_schemas_checked(&old, &new).expect("new generated column should diff");
2847        let add_col = cmds
2848            .iter()
2849            .find(|cmd| matches!(cmd.action, Action::Alter) && cmd.table == "people")
2850            .expect("add-column command should be present");
2851
2852        let Expr::Def { constraints, .. } = &add_col.columns[0] else {
2853            panic!("expected generated column definition");
2854        };
2855        assert!(constraints.iter().any(|constraint| {
2856            matches!(
2857                constraint,
2858                Constraint::Generated(ColumnGeneration::Stored(expr))
2859                    if expr == "first_name || ' ' || last_name"
2860            )
2861        }));
2862
2863        let sql = add_col.to_sql();
2864        assert!(
2865            sql.contains("GENERATED ALWAYS AS (first_name || ' ' || last_name) STORED"),
2866            "add-column SQL should preserve GENERATED clause, got: {sql}"
2867        );
2868    }
2869
2870    #[test]
2871    fn diff_new_table_preserves_foreign_key_actions() {
2872        use super::super::types::ColumnType;
2873        use crate::transpiler::ToSql;
2874
2875        let old = Schema::default();
2876        let mut new = Schema::default();
2877        new.add_table(
2878            Table::new("tenants").column(Column::new("id", ColumnType::Int).primary_key()),
2879        );
2880        new.add_table(
2881            Table::new("orders").column(
2882                Column::new("tenant_id", ColumnType::Int)
2883                    .references("tenants", "id")
2884                    .on_delete(FkAction::Cascade)
2885                    .on_update(FkAction::Restrict),
2886            ),
2887        );
2888
2889        let cmds = diff_schemas_checked(&old, &new).expect("new table with FK should diff");
2890        let make_cmd = cmds
2891            .iter()
2892            .find(|cmd| matches!(cmd.action, Action::Make) && cmd.table == "orders")
2893            .expect("orders create-table command should be present");
2894
2895        let Expr::Def { constraints, .. } = &make_cmd.columns[0] else {
2896            panic!("expected tenant_id column definition");
2897        };
2898        assert!(constraints.iter().any(|constraint| {
2899            matches!(
2900                constraint,
2901                Constraint::References(target)
2902                    if target == "tenants(id) ON DELETE CASCADE ON UPDATE RESTRICT"
2903            )
2904        }));
2905
2906        let sql = make_cmd.to_sql();
2907        assert!(
2908            sql.contains("REFERENCES tenants(id) ON DELETE CASCADE ON UPDATE RESTRICT"),
2909            "create-table SQL should preserve FK action clauses, got: {sql}"
2910        );
2911    }
2912
2913    #[test]
2914    fn diff_new_table_preserves_generated_and_identity_columns() {
2915        use super::super::types::ColumnType;
2916        use crate::transpiler::ToSql;
2917
2918        let old = Schema::default();
2919        let mut new = Schema::default();
2920        new.add_table(
2921            Table::new("people")
2922                .column(Column::new("first_name", ColumnType::Text))
2923                .column(Column::new("last_name", ColumnType::Text))
2924                .column(
2925                    Column::new("full_name", ColumnType::Text)
2926                        .generated_stored("first_name || ' ' || last_name"),
2927                )
2928                .column(Column::new("row_seq", ColumnType::BigInt).generated_by_default()),
2929        );
2930
2931        let cmds = diff_schemas_checked(&old, &new).expect("new table should diff");
2932        let make_cmd = cmds
2933            .iter()
2934            .find(|cmd| matches!(cmd.action, Action::Make) && cmd.table == "people")
2935            .expect("create-table command should be present");
2936
2937        let sql = make_cmd.to_sql();
2938        assert!(
2939            sql.contains("GENERATED ALWAYS AS (first_name || ' ' || last_name) STORED"),
2940            "create-table SQL should preserve GENERATED clause, got: {sql}"
2941        );
2942        assert!(
2943            sql.contains("GENERATED BY DEFAULT AS IDENTITY"),
2944            "create-table SQL should preserve IDENTITY clause, got: {sql}"
2945        );
2946    }
2947
2948    #[test]
2949    fn state_diff_rejects_generated_changes_on_existing_columns() {
2950        use super::super::types::ColumnType;
2951
2952        let mut old = Schema::default();
2953        old.add_table(Table::new("people").column(Column::new("full_name", ColumnType::Text)));
2954
2955        let mut new = Schema::default();
2956        new.add_table(
2957            Table::new("people").column(
2958                Column::new("full_name", ColumnType::Text)
2959                    .generated_stored("first_name || ' ' || last_name"),
2960            ),
2961        );
2962
2963        let err = validate_state_diff_support(&old, &new)
2964            .expect_err("generated changes on existing columns should fail closed");
2965        assert!(err.contains("GENERATED/IDENTITY"), "{err}");
2966        assert!(err.contains("people.full_name"), "{err}");
2967    }
2968
2969    #[test]
2970    fn diff_new_table_emits_rls_commands_after_create() {
2971        use super::super::types::ColumnType;
2972
2973        let old = Schema::default();
2974        let mut new = Schema::default();
2975        let mut docs = Table::new("docs").column(Column::new("id", ColumnType::Int));
2976        docs.enable_rls = true;
2977        docs.force_rls = true;
2978        new.add_table(docs);
2979
2980        let cmds = diff_schemas_checked(&old, &new).expect("new RLS table should diff");
2981        let make_idx = cmds
2982            .iter()
2983            .position(|cmd| matches!(cmd.action, Action::Make) && cmd.table == "docs")
2984            .expect("create-table command should be present");
2985        let enable_idx = cmds
2986            .iter()
2987            .position(|cmd| matches!(cmd.action, Action::AlterEnableRls) && cmd.table == "docs")
2988            .expect("enable RLS command should be present");
2989        let force_idx = cmds
2990            .iter()
2991            .position(|cmd| matches!(cmd.action, Action::AlterForceRls) && cmd.table == "docs")
2992            .expect("force RLS command should be present");
2993
2994        assert!(make_idx < enable_idx);
2995        assert!(enable_idx < force_idx);
2996    }
2997
2998    #[test]
2999    fn state_diff_checked_rejects_existing_table_rls_disable() {
3000        let mut old = Schema::default();
3001        let mut docs = Table::new("docs").column(Column::new("id", ColumnType::Int));
3002        docs.enable_rls = true;
3003        old.add_table(docs);
3004
3005        let mut new = Schema::default();
3006        new.add_table(Table::new("docs").column(Column::new("id", ColumnType::Int)));
3007
3008        let err = diff_schemas_checked(&old, &new)
3009            .expect_err("RLS disable should require an explicit migration");
3010        assert!(err.contains("downgrade RLS"));
3011        assert!(err.contains("docs (disable RLS)"));
3012    }
3013
3014    #[test]
3015    fn state_diff_checked_rejects_existing_table_force_rls_drop() {
3016        let mut old = Schema::default();
3017        let mut docs = Table::new("docs").column(Column::new("id", ColumnType::Int));
3018        docs.enable_rls = true;
3019        docs.force_rls = true;
3020        old.add_table(docs);
3021
3022        let mut new = Schema::default();
3023        let mut docs = Table::new("docs").column(Column::new("id", ColumnType::Int));
3024        docs.enable_rls = true;
3025        new.add_table(docs);
3026
3027        let err = diff_schemas_checked(&old, &new)
3028            .expect_err("FORCE RLS removal should require an explicit migration");
3029        assert!(err.contains("downgrade RLS"));
3030        assert!(err.contains("docs (drop FORCE RLS)"));
3031    }
3032
3033    #[test]
3034    fn diff_dropped_tables_orders_child_before_parent_by_incoming_fk_topology() {
3035        use super::super::types::ColumnType;
3036
3037        let mut old = Schema::default();
3038        old.add_table(
3039            Table::new("root_a").column(Column::new("id", ColumnType::Int).primary_key()),
3040        );
3041        old.add_table(
3042            Table::new("root_b").column(Column::new("id", ColumnType::Int).primary_key()),
3043        );
3044        old.add_table(
3045            Table::new("parent")
3046                .column(Column::new("id", ColumnType::Int).primary_key())
3047                .column(Column::new("root_a_id", ColumnType::Int).references("root_a", "id"))
3048                .column(Column::new("root_b_id", ColumnType::Int).references("root_b", "id")),
3049        );
3050        old.add_table(
3051            Table::new("child")
3052                .column(Column::new("id", ColumnType::Int))
3053                .column(Column::new("parent_id", ColumnType::Int).references("parent", "id")),
3054        );
3055
3056        let mut new = Schema::default();
3057        new.add_table(
3058            Table::new("root_a").column(Column::new("id", ColumnType::Int).primary_key()),
3059        );
3060        new.add_table(
3061            Table::new("root_b").column(Column::new("id", ColumnType::Int).primary_key()),
3062        );
3063
3064        let cmds = diff_schemas_checked(&old, &new).expect("dropped tables should diff");
3065        let child_drop_idx = cmds
3066            .iter()
3067            .position(|cmd| matches!(cmd.action, Action::Drop) && cmd.table == "child")
3068            .expect("child drop should be present");
3069        let parent_drop_idx = cmds
3070            .iter()
3071            .position(|cmd| matches!(cmd.action, Action::Drop) && cmd.table == "parent")
3072            .expect("parent drop should be present");
3073
3074        assert!(
3075            child_drop_idx < parent_drop_idx,
3076            "child table must be dropped before referenced parent table"
3077        );
3078    }
3079
3080    #[test]
3081    fn diff_new_table_preserves_column_check_constraint() {
3082        use super::super::types::ColumnType;
3083        use crate::transpiler::ToSql;
3084
3085        let old = Schema::default();
3086        let mut new = Schema::default();
3087        new.add_table(
3088            Table::new("inventory").column(
3089                Column::new("quantity", ColumnType::Int).not_null().check(
3090                    CheckExpr::GreaterOrEqual {
3091                        column: "quantity".to_string(),
3092                        value: 0,
3093                    },
3094                ),
3095            ),
3096        );
3097
3098        let cmds =
3099            diff_schemas_checked(&old, &new).expect("new table with checked column should diff");
3100        let make_cmd = cmds
3101            .iter()
3102            .find(|cmd| matches!(cmd.action, Action::Make) && cmd.table == "inventory")
3103            .expect("create-table command should be present");
3104
3105        let Expr::Def { constraints, .. } = &make_cmd.columns[0] else {
3106            panic!("expected quantity column definition");
3107        };
3108        assert!(constraints.iter().any(|constraint| {
3109            matches!(
3110                constraint,
3111                Constraint::Check(vals) if vals.len() == 1 && vals[0] == "quantity >= 0"
3112            )
3113        }));
3114
3115        let sql = make_cmd.to_sql();
3116        assert!(
3117            sql.contains("CHECK (quantity >= 0)"),
3118            "create-table SQL should preserve CHECK constraint, got: {sql}"
3119        );
3120    }
3121
3122    #[test]
3123    fn diff_new_partial_unique_index_preserves_predicate() {
3124        use super::super::types::ColumnType;
3125        use crate::transpiler::ToSql;
3126
3127        let mut old = Schema::default();
3128        old.add_table(
3129            Table::new("users")
3130                .column(Column::new("email", ColumnType::Text))
3131                .column(Column::new("deleted_at", ColumnType::Text)),
3132        );
3133
3134        let mut new = old.clone();
3135        new.add_index(
3136            Index::new("idx_users_email_active", "users", vec!["email".to_string()])
3137                .unique()
3138                .partial(CheckExpr::Sql("deleted_at IS NULL".to_string())),
3139        );
3140
3141        let cmds = diff_schemas_checked(&old, &new).expect("new partial index should diff");
3142        let index_cmd = cmds
3143            .iter()
3144            .find(|cmd| matches!(cmd.action, Action::Index))
3145            .expect("index command should be present");
3146        let index_def = index_cmd
3147            .index_def
3148            .as_ref()
3149            .expect("index command should carry index definition");
3150
3151        assert!(index_def.unique);
3152        assert_eq!(index_def.index_type.as_deref(), Some("btree"));
3153        assert_eq!(
3154            index_def.where_clause.as_deref(),
3155            Some("deleted_at IS NULL")
3156        );
3157
3158        let sql = index_cmd.to_sql();
3159        assert!(
3160            sql.contains("WHERE deleted_at IS NULL"),
3161            "index SQL should preserve partial predicate, got: {sql}"
3162        );
3163    }
3164
3165    #[test]
3166    fn diff_new_covering_concurrent_index_preserves_options() {
3167        use super::super::types::ColumnType;
3168        use crate::transpiler::ToSql;
3169
3170        let mut old = Schema::default();
3171        old.add_table(
3172            Table::new("users")
3173                .column(Column::new("email", ColumnType::Text))
3174                .column(Column::new("name", ColumnType::Text))
3175                .column(Column::new("created_at", ColumnType::Timestamp)),
3176        );
3177
3178        let mut new = old.clone();
3179        new.add_index(
3180            Index::new("idx_users_email_cover", "users", vec!["email".to_string()])
3181                .include(vec!["name".to_string(), "created_at".to_string()])
3182                .concurrently(),
3183        );
3184
3185        let cmds =
3186            diff_schemas_checked(&old, &new).expect("new covering concurrent index should diff");
3187        let index_cmd = cmds
3188            .iter()
3189            .find(|cmd| matches!(cmd.action, Action::Index))
3190            .expect("index command should be present");
3191        let index_def = index_cmd
3192            .index_def
3193            .as_ref()
3194            .expect("index command should carry index definition");
3195
3196        assert!(index_def.concurrently);
3197        assert_eq!(
3198            index_def.include,
3199            vec!["name".to_string(), "created_at".to_string()]
3200        );
3201
3202        let sql = index_cmd.to_sql();
3203        assert!(
3204            sql.contains("CREATE INDEX CONCURRENTLY idx_users_email_cover"),
3205            "index SQL should preserve CONCURRENTLY, got: {sql}"
3206        );
3207        assert!(
3208            sql.contains("INCLUDE (name, created_at)"),
3209            "index SQL should preserve INCLUDE columns, got: {sql}"
3210        );
3211    }
3212
3213    #[test]
3214    fn test_diff_rename_with_hint() {
3215        use super::super::types::ColumnType;
3216        let mut old = Schema::default();
3217        old.add_table(Table::new("users").column(Column::new("username", ColumnType::Text)));
3218
3219        let mut new = Schema::default();
3220        new.add_table(Table::new("users").column(Column::new("name", ColumnType::Text)));
3221        new.add_hint(MigrationHint::Rename {
3222            from: "users.username".into(),
3223            to: "users.name".into(),
3224        });
3225
3226        let cmds = diff_schemas(&old, &new);
3227        // Should have rename, NOT drop + add
3228        assert!(cmds.iter().any(|c| matches!(c.action, Action::Mod)));
3229        assert!(!cmds.iter().any(|c| matches!(c.action, Action::AlterDrop)));
3230    }
3231
3232    #[test]
3233    fn rename_hint_does_not_suppress_same_named_add_column_in_other_table() {
3234        use super::super::types::ColumnType;
3235
3236        let mut old = Schema::default();
3237        old.add_table(Table::new("users").column(Column::new("username", ColumnType::Text)));
3238        old.add_table(Table::new("profiles").column(Column::new("id", ColumnType::Int)));
3239
3240        let mut new = Schema::default();
3241        new.add_table(Table::new("users").column(Column::new("name", ColumnType::Text)));
3242        new.add_table(
3243            Table::new("profiles")
3244                .column(Column::new("id", ColumnType::Int))
3245                .column(Column::new("name", ColumnType::Text)),
3246        );
3247        new.add_hint(MigrationHint::Rename {
3248            from: "users.username".into(),
3249            to: "users.name".into(),
3250        });
3251
3252        let cmds = diff_schemas_checked(&old, &new).expect("schema should diff");
3253
3254        assert!(cmds.iter().any(|cmd| {
3255            matches!(cmd.action, Action::Alter)
3256                && cmd.table == "profiles"
3257                && matches!(
3258                    cmd.columns.first(),
3259                    Some(Expr::Def { name, .. }) if name == "name"
3260                )
3261        }));
3262    }
3263
3264    #[test]
3265    fn rename_hint_does_not_suppress_same_named_drop_column_in_other_table() {
3266        use super::super::types::ColumnType;
3267
3268        let mut old = Schema::default();
3269        old.add_table(Table::new("users").column(Column::new("username", ColumnType::Text)));
3270        old.add_table(
3271            Table::new("profiles")
3272                .column(Column::new("id", ColumnType::Int))
3273                .column(Column::new("username", ColumnType::Text)),
3274        );
3275
3276        let mut new = Schema::default();
3277        new.add_table(Table::new("users").column(Column::new("name", ColumnType::Text)));
3278        new.add_table(Table::new("profiles").column(Column::new("id", ColumnType::Int)));
3279        new.add_hint(MigrationHint::Rename {
3280            from: "users.username".into(),
3281            to: "users.name".into(),
3282        });
3283
3284        let cmds = diff_schemas_checked(&old, &new).expect("schema should diff");
3285
3286        assert!(cmds.iter().any(|cmd| {
3287            matches!(cmd.action, Action::AlterDrop)
3288                && cmd.table == "profiles"
3289                && matches!(
3290                    cmd.columns.first(),
3291                    Some(Expr::Named(name)) if name == "username"
3292                )
3293        }));
3294    }
3295
3296    /// Regression test: FK parent tables must be created before child tables
3297    #[test]
3298    fn test_fk_ordering_parent_before_child() {
3299        use super::super::types::ColumnType;
3300
3301        let old = Schema::default();
3302
3303        let mut new = Schema::default();
3304        // Child table with FK to parent
3305        new.add_table(
3306            Table::new("child")
3307                .column(Column::new("id", ColumnType::Serial).primary_key())
3308                .column(Column::new("parent_id", ColumnType::Int).references("parent", "id")),
3309        );
3310        // Parent table (no FK)
3311        new.add_table(
3312            Table::new("parent")
3313                .column(Column::new("id", ColumnType::Serial).primary_key())
3314                .column(Column::new("name", ColumnType::Text)),
3315        );
3316
3317        let cmds = diff_schemas(&old, &new);
3318
3319        // Should have 2 CREATE TABLE commands
3320        let make_cmds: Vec<_> = cmds
3321            .iter()
3322            .filter(|c| matches!(c.action, Action::Make))
3323            .collect();
3324        assert_eq!(make_cmds.len(), 2);
3325
3326        // Parent (0 FKs) should come BEFORE child (1 FK)
3327        let parent_idx = make_cmds.iter().position(|c| c.table == "parent").unwrap();
3328        let child_idx = make_cmds.iter().position(|c| c.table == "child").unwrap();
3329        assert!(
3330            parent_idx < child_idx,
3331            "parent table should be created before child with FK"
3332        );
3333    }
3334
3335    /// Regression test: Multiple FK dependencies should be sorted correctly
3336    #[test]
3337    fn test_fk_ordering_multiple_dependencies() {
3338        use super::super::types::ColumnType;
3339
3340        let old = Schema::default();
3341
3342        let mut new = Schema::default();
3343        // Table with 2 FKs (should be last)
3344        new.add_table(
3345            Table::new("order_items")
3346                .column(Column::new("id", ColumnType::Serial).primary_key())
3347                .column(Column::new("order_id", ColumnType::Int).references("orders", "id"))
3348                .column(Column::new("product_id", ColumnType::Int).references("products", "id")),
3349        );
3350        // Table with 1 FK (should be middle)
3351        new.add_table(
3352            Table::new("orders")
3353                .column(Column::new("id", ColumnType::Serial).primary_key())
3354                .column(Column::new("user_id", ColumnType::Int).references("users", "id")),
3355        );
3356        // Table with 0 FKs (should be first)
3357        new.add_table(
3358            Table::new("users").column(Column::new("id", ColumnType::Serial).primary_key()),
3359        );
3360        new.add_table(
3361            Table::new("products").column(Column::new("id", ColumnType::Serial).primary_key()),
3362        );
3363
3364        let cmds = diff_schemas(&old, &new);
3365
3366        let make_cmds: Vec<_> = cmds
3367            .iter()
3368            .filter(|c| matches!(c.action, Action::Make))
3369            .collect();
3370        assert_eq!(make_cmds.len(), 4);
3371
3372        // Get positions
3373        let users_idx = make_cmds.iter().position(|c| c.table == "users").unwrap();
3374        let products_idx = make_cmds
3375            .iter()
3376            .position(|c| c.table == "products")
3377            .unwrap();
3378        let orders_idx = make_cmds.iter().position(|c| c.table == "orders").unwrap();
3379        let items_idx = make_cmds
3380            .iter()
3381            .position(|c| c.table == "order_items")
3382            .unwrap();
3383
3384        // Tables with 0 FKs should come first
3385        assert!(users_idx < orders_idx, "users (0 FK) before orders (1 FK)");
3386        assert!(
3387            products_idx < items_idx,
3388            "products (0 FK) before order_items (2 FK)"
3389        );
3390
3391        // orders (1 FK) should come before order_items (2 FKs)
3392        assert!(
3393            orders_idx < items_idx,
3394            "orders (1 FK) before order_items (2 FK)"
3395        );
3396    }
3397
3398    #[test]
3399    fn diff_new_table_preserves_multi_column_foreign_key() {
3400        use super::super::types::ColumnType;
3401        use crate::transpiler::ToSql;
3402
3403        let old = Schema::default();
3404
3405        let mut new = Schema::default();
3406        new.add_table(
3407            Table::new("schedules")
3408                .column(Column::new("route_id", ColumnType::Text))
3409                .column(Column::new("schedule_id", ColumnType::Text)),
3410        );
3411        new.add_index(
3412            Index::new(
3413                "idx_schedules_route_schedule",
3414                "schedules",
3415                vec!["route_id".to_string(), "schedule_id".to_string()],
3416            )
3417            .unique(),
3418        );
3419        new.add_table(
3420            Table::new("trips")
3421                .column(Column::new("route_id", ColumnType::Text))
3422                .column(Column::new("schedule_id", ColumnType::Text))
3423                .foreign_key(MultiColumnForeignKey::new(
3424                    vec!["route_id".to_string(), "schedule_id".to_string()],
3425                    "schedules",
3426                    vec!["route_id".to_string(), "schedule_id".to_string()],
3427                )),
3428        );
3429
3430        let cmds = diff_schemas(&old, &new);
3431        let schedules_idx = cmds
3432            .iter()
3433            .position(|c| matches!(c.action, Action::Make) && c.table == "schedules")
3434            .expect("schedules create command should exist");
3435        let trips_idx = cmds
3436            .iter()
3437            .position(|c| matches!(c.action, Action::Make) && c.table == "trips")
3438            .expect("trips create command should exist");
3439        let unique_idx = cmds
3440            .iter()
3441            .position(|c| {
3442                matches!(c.action, Action::Index)
3443                    && c.index_def
3444                        .as_ref()
3445                        .is_some_and(|idx| idx.name == "idx_schedules_route_schedule")
3446            })
3447            .expect("unique index command should exist");
3448        let add_fk_idx = cmds
3449            .iter()
3450            .position(|c| matches!(c.action, Action::Alter) && c.table == "trips")
3451            .expect("composite FK ALTER command should exist");
3452
3453        assert!(schedules_idx < unique_idx);
3454        assert!(trips_idx < unique_idx);
3455        assert!(unique_idx < add_fk_idx);
3456
3457        let trips_cmd = cmds
3458            .iter()
3459            .find(|c| matches!(c.action, Action::Make) && c.table == "trips")
3460            .expect("trips create command should exist");
3461        assert!(
3462            trips_cmd.table_constraints.is_empty(),
3463            "composite foreign keys should not be emitted inline on CREATE TABLE"
3464        );
3465
3466        let add_fk_cmd = &cmds[add_fk_idx];
3467        assert!(
3468            add_fk_cmd
3469                .table_constraints
3470                .iter()
3471                .any(|constraint| matches!(
3472                    constraint,
3473                    crate::ast::TableConstraint::ForeignKey {
3474                        columns,
3475                        ref_table,
3476                        ref_columns,
3477                        ..
3478                    } if columns == &["route_id", "schedule_id"]
3479                        && ref_table == "schedules"
3480                        && ref_columns == &["route_id", "schedule_id"]
3481                )),
3482            "diff should preserve composite FK table constraint"
3483        );
3484
3485        let sql = add_fk_cmd.to_sql();
3486        assert!(
3487            sql.contains(
3488                "ALTER TABLE trips ADD FOREIGN KEY (route_id, schedule_id) REFERENCES schedules(route_id, schedule_id)"
3489            ),
3490            "generated SQL should include composite foreign key, got: {sql}"
3491        );
3492    }
3493
3494    #[test]
3495    fn diff_existing_table_adds_multi_column_foreign_key() {
3496        use super::super::types::ColumnType;
3497        use crate::transpiler::ToSql;
3498
3499        let mut old = Schema::default();
3500        old.add_table(
3501            Table::new("schedules")
3502                .column(Column::new("route_id", ColumnType::Text))
3503                .column(Column::new("schedule_id", ColumnType::Text)),
3504        );
3505        old.add_table(
3506            Table::new("trips")
3507                .column(Column::new("route_id", ColumnType::Text))
3508                .column(Column::new("schedule_id", ColumnType::Text)),
3509        );
3510
3511        let mut new = old.clone();
3512        new.add_index(
3513            Index::new(
3514                "idx_schedules_route_schedule",
3515                "schedules",
3516                vec!["route_id".to_string(), "schedule_id".to_string()],
3517            )
3518            .unique(),
3519        );
3520        new.tables
3521            .get_mut("trips")
3522            .expect("trips table should exist")
3523            .multi_column_fks
3524            .push(MultiColumnForeignKey::new(
3525                vec!["route_id".to_string(), "schedule_id".to_string()],
3526                "schedules",
3527                vec!["route_id".to_string(), "schedule_id".to_string()],
3528            ));
3529
3530        let cmds = diff_schemas(&old, &new);
3531        let unique_idx = cmds
3532            .iter()
3533            .position(|c| {
3534                matches!(c.action, Action::Index)
3535                    && c.index_def
3536                        .as_ref()
3537                        .is_some_and(|idx| idx.name == "idx_schedules_route_schedule")
3538            })
3539            .expect("unique index command should exist");
3540        let add_fk_idx = cmds
3541            .iter()
3542            .position(|c| matches!(c.action, Action::Alter) && c.table == "trips")
3543            .expect("composite FK ALTER command should exist");
3544        assert!(unique_idx < add_fk_idx);
3545
3546        let add_fk_cmd = &cmds[add_fk_idx];
3547        let sql = add_fk_cmd.to_sql();
3548        assert!(
3549            sql.contains(
3550                "ALTER TABLE trips ADD FOREIGN KEY (route_id, schedule_id) REFERENCES schedules(route_id, schedule_id)"
3551            ),
3552            "generated SQL should add composite foreign key, got: {sql}"
3553        );
3554    }
3555
3556    #[test]
3557    fn state_diff_support_rejects_added_multi_column_foreign_key_on_existing_table() {
3558        use super::super::types::ColumnType;
3559
3560        let mut old = Schema::default();
3561        old.add_table(
3562            Table::new("schedules")
3563                .column(Column::new("route_id", ColumnType::Text))
3564                .column(Column::new("schedule_id", ColumnType::Text)),
3565        );
3566        old.add_index(
3567            Index::new(
3568                "idx_schedules_route_schedule",
3569                "schedules",
3570                vec!["route_id".to_string(), "schedule_id".to_string()],
3571            )
3572            .unique(),
3573        );
3574        old.add_table(
3575            Table::new("trips")
3576                .column(Column::new("route_id", ColumnType::Text))
3577                .column(Column::new("schedule_id", ColumnType::Text)),
3578        );
3579
3580        let mut new = old.clone();
3581        new.tables
3582            .get_mut("trips")
3583            .expect("trips table should exist")
3584            .multi_column_fks
3585            .push(MultiColumnForeignKey::new(
3586                vec!["route_id".to_string(), "schedule_id".to_string()],
3587                "schedules",
3588                vec!["route_id".to_string(), "schedule_id".to_string()],
3589            ));
3590
3591        let err =
3592            diff_schemas_checked(&old, &new).expect_err("added composite FK should fail closed");
3593        assert!(err.contains("add multi-column foreign keys"));
3594        assert!(err.contains("trips."));
3595    }
3596
3597    #[test]
3598    fn state_diff_support_rejects_removed_multi_column_foreign_key() {
3599        use super::super::types::ColumnType;
3600
3601        let mut old = Schema::default();
3602        old.add_table(
3603            Table::new("schedules")
3604                .column(Column::new("route_id", ColumnType::Text))
3605                .column(Column::new("schedule_id", ColumnType::Text)),
3606        );
3607        old.add_index(
3608            Index::new(
3609                "idx_schedules_route_schedule",
3610                "schedules",
3611                vec!["route_id".to_string(), "schedule_id".to_string()],
3612            )
3613            .unique(),
3614        );
3615        old.add_table(
3616            Table::new("trips")
3617                .column(Column::new("route_id", ColumnType::Text))
3618                .column(Column::new("schedule_id", ColumnType::Text))
3619                .foreign_key(MultiColumnForeignKey::new(
3620                    vec!["route_id".to_string(), "schedule_id".to_string()],
3621                    "schedules",
3622                    vec!["route_id".to_string(), "schedule_id".to_string()],
3623                )),
3624        );
3625
3626        let mut new = old.clone();
3627        new.tables
3628            .get_mut("trips")
3629            .expect("trips table should exist")
3630            .multi_column_fks
3631            .clear();
3632
3633        let err =
3634            diff_schemas_checked(&old, &new).expect_err("removed composite FK should fail closed");
3635        assert!(err.contains("multi-column foreign keys"));
3636        assert!(err.contains("trips."));
3637    }
3638
3639    #[test]
3640    fn state_diff_support_rejects_changed_multi_column_foreign_key() {
3641        use super::super::types::ColumnType;
3642
3643        let mut old = Schema::default();
3644        old.add_table(
3645            Table::new("schedules")
3646                .column(Column::new("route_id", ColumnType::Text))
3647                .column(Column::new("schedule_id", ColumnType::Text)),
3648        );
3649        old.add_index(
3650            Index::new(
3651                "idx_schedules_route_schedule",
3652                "schedules",
3653                vec!["route_id".to_string(), "schedule_id".to_string()],
3654            )
3655            .unique(),
3656        );
3657        old.add_table(
3658            Table::new("trips")
3659                .column(Column::new("route_id", ColumnType::Text))
3660                .column(Column::new("schedule_id", ColumnType::Text))
3661                .foreign_key(MultiColumnForeignKey::new(
3662                    vec!["route_id".to_string(), "schedule_id".to_string()],
3663                    "schedules",
3664                    vec!["route_id".to_string(), "schedule_id".to_string()],
3665                )),
3666        );
3667
3668        let mut new = old.clone();
3669        new.tables
3670            .get_mut("trips")
3671            .expect("trips table should exist")
3672            .multi_column_fks[0] = MultiColumnForeignKey::new(
3673            vec!["schedule_id".to_string(), "route_id".to_string()],
3674            "schedules",
3675            vec!["route_id".to_string(), "schedule_id".to_string()],
3676        );
3677
3678        let err =
3679            diff_schemas_checked(&old, &new).expect_err("changed composite FK should fail closed");
3680        assert!(err.contains("multi-column foreign keys"));
3681        assert!(err.contains("trips."));
3682    }
3683}