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