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