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