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