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