1use super::collection::{DiffType, EntityDiff, PostgresDDL, diff_ddl};
7use super::statements::{Generator, JsonStatement};
8use crate::postgres::ddl::PostgresEntity;
9use crate::postgres::snapshot::PostgresSnapshot;
10use crate::traits::EntityKind;
11use std::borrow::Cow;
12use std::collections::{BTreeMap, HashSet};
13
14#[derive(Debug, Clone, Default)]
16pub struct SchemaDiff {
17 pub diffs: Vec<EntityDiff>,
18}
19
20impl SchemaDiff {
21 #[must_use]
22 pub const fn has_changes(&self) -> bool {
23 !self.diffs.is_empty()
24 }
25
26 #[must_use]
27 pub const fn is_empty(&self) -> bool {
28 self.diffs.is_empty()
29 }
30
31 #[must_use]
33 pub fn created(&self) -> Vec<&EntityDiff> {
34 self.diffs
35 .iter()
36 .filter(|d| d.diff_type == DiffType::Create)
37 .collect()
38 }
39
40 #[must_use]
42 pub fn dropped(&self) -> Vec<&EntityDiff> {
43 self.diffs
44 .iter()
45 .filter(|d| d.diff_type == DiffType::Drop)
46 .collect()
47 }
48
49 #[must_use]
51 pub fn altered(&self) -> Vec<&EntityDiff> {
52 self.diffs
53 .iter()
54 .filter(|d| d.diff_type == DiffType::Alter)
55 .collect()
56 }
57
58 #[must_use]
60 pub fn by_kind(&self, kind: EntityKind) -> Vec<&EntityDiff> {
61 self.diffs.iter().filter(|d| d.kind == kind).collect()
62 }
63
64 #[must_use]
66 pub fn created_tables(&self) -> Vec<&EntityDiff> {
67 self.diffs
68 .iter()
69 .filter(|d| d.diff_type == DiffType::Create && d.kind == EntityKind::Table)
70 .collect()
71 }
72
73 #[must_use]
75 pub fn dropped_tables(&self) -> Vec<&EntityDiff> {
76 self.diffs
77 .iter()
78 .filter(|d| d.diff_type == DiffType::Drop && d.kind == EntityKind::Table)
79 .collect()
80 }
81
82 #[must_use]
84 pub fn created_schemas(&self) -> Vec<&EntityDiff> {
85 self.diffs
86 .iter()
87 .filter(|d| d.diff_type == DiffType::Create && d.kind == EntityKind::Schema)
88 .collect()
89 }
90
91 #[must_use]
93 pub fn created_enums(&self) -> Vec<&EntityDiff> {
94 self.diffs
95 .iter()
96 .filter(|d| d.diff_type == DiffType::Create && d.kind == EntityKind::Enum)
97 .collect()
98 }
99}
100
101#[must_use]
103pub fn diff_snapshots(prev_ddl: &[PostgresEntity], cur_ddl: &[PostgresEntity]) -> SchemaDiff {
104 let left = PostgresDDL::from_entities(prev_ddl.to_vec());
105 let right = PostgresDDL::from_entities(cur_ddl.to_vec());
106 let diffs = diff_ddl(&left, &right);
107
108 SchemaDiff { diffs }
109}
110
111#[must_use]
113pub fn diff_collections(prev: &PostgresDDL, cur: &PostgresDDL) -> SchemaDiff {
114 SchemaDiff {
115 diffs: diff_ddl(prev, cur),
116 }
117}
118
119#[must_use]
121pub fn diff_full_snapshots(prev: &PostgresSnapshot, cur: &PostgresSnapshot) -> SchemaDiff {
122 diff_snapshots(&prev.ddl, &cur.ddl)
123}
124
125#[derive(Debug, Clone)]
131pub struct SchemaRename {
132 pub from: String,
133 pub to: String,
134}
135
136#[derive(Debug, Clone)]
138pub struct TableRename {
139 pub schema: String,
140 pub from: String,
141 pub to: String,
142}
143
144#[derive(Debug, Clone)]
146pub struct ColumnRename {
147 pub schema: String,
148 pub table: String,
149 pub from: String,
150 pub to: String,
151}
152
153#[derive(Debug, Clone, Default)]
155pub struct MigrationDiff {
156 pub sql_statements: Vec<String>,
158 pub renames: Vec<String>,
160 pub warnings: Vec<String>,
162}
163
164#[must_use]
166pub fn compute_migration(prev: &PostgresDDL, cur: &PostgresDDL) -> MigrationDiff {
167 let mut prev_normalized = prev.clone();
172 let mut schema_renames: Vec<SchemaRename> = Vec::new();
173 let mut table_renames: Vec<TableRename> = Vec::new();
174 let mut column_renames: Vec<ColumnRename> = Vec::new();
175 let mut rename_statements: Vec<JsonStatement> = Vec::new();
176 let mut warnings = Vec::new();
177
178 detect_and_apply_schema_renames(
179 &mut prev_normalized,
180 cur,
181 &mut schema_renames,
182 &mut rename_statements,
183 &mut warnings,
184 );
185 detect_and_apply_table_renames(
186 &mut prev_normalized,
187 cur,
188 &mut table_renames,
189 &mut rename_statements,
190 &mut warnings,
191 );
192
193 detect_and_apply_column_renames(
194 &mut prev_normalized,
195 cur,
196 &mut column_renames,
197 &mut rename_statements,
198 );
199
200 let schema_diff = diff_collections(&prev_normalized, cur);
201 let generator = Generator::new();
202 let mut sql_statements = rename_statements
203 .into_iter()
204 .flat_map(Generator::statement_to_sqls)
205 .collect::<Vec<_>>();
206 sql_statements.extend(generator.generate_with_ddl(&schema_diff.diffs, Some(cur)));
207 collect_enum_removal_warnings(&mut warnings, &schema_diff);
208 collect_generated_recreate_warnings(&mut warnings, &schema_diff);
209 collect_table_storage_warnings(&mut warnings, &schema_diff);
210
211 MigrationDiff {
212 sql_statements,
213 renames: prepare_migration_renames(&schema_renames, &table_renames, &column_renames),
214 warnings,
215 }
216}
217
218fn quote_ident(ident: &str) -> String {
219 format!("\"{}\"", ident.replace('"', "\"\""))
220}
221
222fn qualified_name(schema: &str, table: &str) -> String {
223 if schema == "public" {
224 quote_ident(table)
225 } else {
226 format!("{}.{}", quote_ident(schema), quote_ident(table))
227 }
228}
229
230fn collect_enum_removal_warnings(warnings: &mut Vec<String>, schema_diff: &SchemaDiff) {
231 for diff in schema_diff
232 .diffs
233 .iter()
234 .filter(|diff| diff.diff_type == DiffType::Alter && diff.kind == EntityKind::Enum)
235 {
236 let (Some(PostgresEntity::Enum(old)), Some(PostgresEntity::Enum(new))) =
237 (diff.left.as_ref(), diff.right.as_ref())
238 else {
239 continue;
240 };
241
242 for value in old
243 .values
244 .iter()
245 .filter(|old_value| !new.values.iter().any(|new_value| new_value == *old_value))
246 {
247 warnings.push(format!(
248 "PostgreSQL cannot drop enum value '{}.{}.{value}' in place; the migration recreates the enum type, and rows still holding the removed value will fail the conversion. Rewrite dependent data first.",
249 old.schema, old.name
250 ));
251 }
252 }
253}
254
255fn collect_generated_recreate_warnings(warnings: &mut Vec<String>, schema_diff: &SchemaDiff) {
256 for diff in schema_diff
257 .diffs
258 .iter()
259 .filter(|diff| diff.diff_type == DiffType::Alter && diff.kind == EntityKind::Column)
260 {
261 let (Some(PostgresEntity::Column(old)), Some(PostgresEntity::Column(new))) =
262 (diff.left.as_ref(), diff.right.as_ref())
263 else {
264 continue;
265 };
266
267 if old.generated.is_none() && new.generated.is_some() {
268 warnings.push(format!(
269 "Adding a generated expression to {}.{} drops and recreates the column; existing column data will be lost.",
270 qualified_name(&new.schema, &new.table),
271 quote_ident(&new.name)
272 ));
273 }
274 }
275}
276
277fn collect_table_storage_warnings(warnings: &mut Vec<String>, schema_diff: &SchemaDiff) {
278 for diff in schema_diff
279 .diffs
280 .iter()
281 .filter(|diff| diff.diff_type == DiffType::Alter && diff.kind == EntityKind::Table)
282 {
283 let (Some(PostgresEntity::Table(old)), Some(PostgresEntity::Table(new))) =
284 (diff.left.as_ref(), diff.right.as_ref())
285 else {
286 continue;
287 };
288
289 if old.is_temporary.unwrap_or(false) != new.is_temporary.unwrap_or(false) {
290 warnings.push(format!(
291 "PostgreSQL cannot alter temporary table status for {}; write a manual migration to recreate the table if needed.",
292 qualified_name(&new.schema, &new.name)
293 ));
294 }
295
296 if old.inherits.as_deref() != new.inherits.as_deref() {
297 warnings.push(format!(
298 "PostgreSQL table inheritance changes for {} are not emitted automatically; write a manual migration if needed.",
299 qualified_name(&new.schema, &new.name)
300 ));
301 }
302 }
303}
304
305#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
306struct TableColumnFingerprint {
307 name: String,
308 sql_type: String,
309 not_null: bool,
310}
311
312#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
313struct TableFingerprint {
314 columns: Vec<TableColumnFingerprint>,
315 pk_columns: Vec<String>,
316}
317
318#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
319struct SchemaTableFingerprint {
320 name: String,
321 table: TableFingerprint,
322}
323
324#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
325struct SchemaFingerprint {
326 tables: Vec<SchemaTableFingerprint>,
327}
328
329fn table_fingerprint(schema: &str, table: &str, ddl: &PostgresDDL) -> TableFingerprint {
330 let mut columns: Vec<_> = ddl
331 .columns
332 .for_table(schema, table)
333 .into_iter()
334 .map(|c| TableColumnFingerprint {
335 name: c.name.to_string(),
336 sql_type: crate::postgres::collection::normalize_column_type_for_compare(c),
340 not_null: c.not_null,
341 })
342 .collect();
343 columns.sort();
344
345 let pk_columns = ddl
346 .pks
347 .for_table(schema, table)
348 .map_or_else(Vec::new, |pk| {
349 pk.columns.iter().map(ToString::to_string).collect()
350 });
351
352 TableFingerprint {
353 columns,
354 pk_columns,
355 }
356}
357
358fn schema_fingerprint(schema: &str, ddl: &PostgresDDL) -> SchemaFingerprint {
359 let mut tables: Vec<_> = ddl
360 .tables
361 .list()
362 .iter()
363 .filter(|table| table.schema.as_ref() == schema)
364 .map(|table| SchemaTableFingerprint {
365 name: table.name.to_string(),
366 table: table_fingerprint(schema, &table.name, ddl),
367 })
368 .collect();
369 tables.sort();
370
371 SchemaFingerprint { tables }
372}
373
374fn detect_and_apply_schema_renames(
375 prev: &mut PostgresDDL,
376 cur: &PostgresDDL,
377 schema_renames: &mut Vec<SchemaRename>,
378 rename_statements: &mut Vec<JsonStatement>,
379 warnings: &mut Vec<String>,
380) {
381 let prev_schemas: Vec<String> = prev
382 .schemas
383 .list()
384 .iter()
385 .map(|schema| schema.name.to_string())
386 .collect();
387 let cur_schemas: Vec<String> = cur
388 .schemas
389 .list()
390 .iter()
391 .map(|schema| schema.name.to_string())
392 .collect();
393
394 let dropped: Vec<String> = prev_schemas
395 .iter()
396 .filter(|schema| !cur_schemas.contains(schema))
397 .cloned()
398 .collect();
399 let created: Vec<String> = cur_schemas
400 .iter()
401 .filter(|schema| !prev_schemas.contains(schema))
402 .cloned()
403 .collect();
404
405 let mut candidates: BTreeMap<SchemaFingerprint, (Vec<String>, Vec<String>)> = BTreeMap::new();
406 for from in dropped {
407 candidates
408 .entry(schema_fingerprint(&from, prev))
409 .or_default()
410 .0
411 .push(from);
412 }
413 for to in created {
414 candidates
415 .entry(schema_fingerprint(&to, cur))
416 .or_default()
417 .1
418 .push(to);
419 }
420
421 for (_, (mut dropped, mut created)) in candidates {
422 if dropped.is_empty() || created.is_empty() {
423 continue;
424 }
425
426 dropped.sort();
427 created.sort();
428
429 if dropped.len() == 1 && created.len() == 1 {
430 let from = &dropped[0];
431 let to = &created[0];
432 let (Some(from_schema), Some(to_schema)) = (
433 prev.schemas.one(from).cloned(),
434 cur.schemas.one(to).cloned(),
435 ) else {
436 continue;
437 };
438
439 schema_renames.push(SchemaRename {
440 from: from.clone(),
441 to: to.clone(),
442 });
443 rename_statements.push(JsonStatement::RenameSchema {
444 from: from_schema,
445 to: to_schema,
446 });
447 apply_schema_rename(prev, from, to);
448 } else {
449 warnings.push(format!(
450 "Ambiguous PostgreSQL schema rename candidates between dropped schemas [{}] and created schemas [{}]; no rename was inferred. Use DiffOptions::rename_schema(...) with diff_with or diff_schemas_with to provide an explicit rename hint.",
451 dropped.join(", "),
452 created.join(", ")
453 ));
454 }
455 }
456}
457
458fn detect_and_apply_table_renames(
459 prev: &mut PostgresDDL,
460 cur: &PostgresDDL,
461 table_renames: &mut Vec<TableRename>,
462 rename_statements: &mut Vec<JsonStatement>,
463 warnings: &mut Vec<String>,
464) {
465 let prev_tables: HashSet<(String, String)> = prev
466 .tables
467 .list()
468 .iter()
469 .map(|table| (table.schema.to_string(), table.name.to_string()))
470 .collect();
471 let cur_tables: HashSet<(String, String)> = cur
472 .tables
473 .list()
474 .iter()
475 .map(|table| (table.schema.to_string(), table.name.to_string()))
476 .collect();
477
478 let dropped: Vec<(String, String)> = prev_tables
479 .iter()
480 .filter(|table| !cur_tables.contains(*table))
481 .cloned()
482 .collect();
483 let created: Vec<(String, String)> = cur_tables
484 .iter()
485 .filter(|table| !prev_tables.contains(*table))
486 .cloned()
487 .collect();
488
489 let mut candidates: BTreeMap<(String, TableFingerprint), (Vec<String>, Vec<String>)> =
490 BTreeMap::new();
491 for (schema, from) in dropped {
492 candidates
493 .entry((schema.clone(), table_fingerprint(&schema, &from, prev)))
494 .or_default()
495 .0
496 .push(from);
497 }
498 for (schema, to) in created {
499 candidates
500 .entry((schema.clone(), table_fingerprint(&schema, &to, cur)))
501 .or_default()
502 .1
503 .push(to);
504 }
505
506 for ((schema, _), (mut dropped, mut created)) in candidates {
507 if dropped.is_empty() || created.is_empty() {
508 continue;
509 }
510
511 dropped.sort();
512 created.sort();
513
514 if dropped.len() == 1 && created.len() == 1 {
515 let from = &dropped[0];
516 let to = &created[0];
517 table_renames.push(TableRename {
518 schema: schema.clone(),
519 from: from.clone(),
520 to: to.clone(),
521 });
522 rename_statements.push(JsonStatement::RenameTable {
523 schema: schema.clone(),
524 from: from.clone(),
525 to: to.clone(),
526 });
527 apply_table_rename(prev, &schema, from, to);
528 } else {
529 warnings.push(format!(
530 "Ambiguous PostgreSQL table rename candidates in schema '{}' between dropped tables [{}] and created tables [{}]; no rename was inferred. Use DiffOptions::rename_table_in(...) with diff_with or diff_schemas_with to provide an explicit rename hint.",
531 schema,
532 dropped.join(", "),
533 created.join(", ")
534 ));
535 }
536 }
537}
538
539fn detect_and_apply_column_renames(
540 prev: &mut PostgresDDL,
541 cur: &PostgresDDL,
542 out: &mut Vec<ColumnRename>,
543 rename_statements: &mut Vec<JsonStatement>,
544) {
545 let common_tables: Vec<(String, String)> = prev
546 .tables
547 .list()
548 .iter()
549 .map(|t| (t.schema.to_string(), t.name.to_string()))
550 .filter(|(schema, table)| cur.tables.one(schema, table).is_some())
551 .collect();
552
553 for (schema, table) in common_tables {
554 let prev_cols = prev.columns.for_table(&schema, &table);
555 let cur_cols = cur.columns.for_table(&schema, &table);
556
557 let prev_names: HashSet<String> = prev_cols.iter().map(|c| c.name.to_string()).collect();
558 let cur_names: HashSet<String> = cur_cols.iter().map(|c| c.name.to_string()).collect();
559
560 let dropped: Vec<String> = prev_names.difference(&cur_names).cloned().collect();
561 let created: Vec<String> = cur_names.difference(&prev_names).cloned().collect();
562
563 if dropped.len() != 1 || created.len() != 1 {
564 continue;
565 }
566
567 let from = &dropped[0];
568 let to = &created[0];
569
570 let prev_col = prev.columns.one(&schema, &table, from);
571 let cur_col = cur.columns.one(&schema, &table, to);
572 if let (Some(prev_col), Some(cur_col)) = (prev_col, cur_col) {
573 let mut prev_cmp = prev_col.clone();
574 prev_cmp.name.clone_from(&cur_col.name);
575 if crate::postgres::collection::columns_equivalent(&prev_cmp, cur_col) {
579 out.push(ColumnRename {
580 schema: schema.clone(),
581 table: table.clone(),
582 from: from.clone(),
583 to: to.clone(),
584 });
585 rename_statements.push(JsonStatement::RenameColumn {
586 from: Box::new(prev_col.clone()),
587 to: Box::new(cur_col.clone()),
588 });
589 apply_column_rename(prev, &schema, &table, from, to);
590 }
591 }
592 }
593}
594
595fn rewrite_cow(value: &mut Cow<'static, str>, from: &str, to: &str) {
596 if value.as_ref() == from {
597 *value = to.to_string().into();
598 }
599}
600
601fn rewrite_optional_cow(value: &mut Option<Cow<'static, str>>, from: &str, to: &str) {
602 if value.as_deref() == Some(from) {
603 *value = Some(to.to_string().into());
604 }
605}
606
607fn rewrite_schema_qualified_value(value: &mut Option<Cow<'static, str>>, from: &str, to: &str) {
608 let Some(current) = value.as_deref() else {
609 return;
610 };
611 let Some(rest) = current
612 .strip_prefix(from)
613 .and_then(|rest| rest.strip_prefix('.'))
614 else {
615 return;
616 };
617 *value = Some(format!("{to}.{rest}").into());
618}
619
620fn apply_schema_rename(ddl: &mut PostgresDDL, from: &str, to: &str) {
621 for schema in ddl.schemas.list_mut() {
622 rewrite_cow(&mut schema.name, from, to);
623 }
624
625 for table in ddl.tables.list_mut() {
626 rewrite_cow(&mut table.schema, from, to);
627 rewrite_schema_qualified_value(&mut table.inherits, from, to);
628 }
629
630 for column in ddl.columns.list_mut() {
631 rewrite_cow(&mut column.schema, from, to);
632 rewrite_optional_cow(&mut column.type_schema, from, to);
633 if let Some(identity) = &mut column.identity {
634 rewrite_optional_cow(&mut identity.schema, from, to);
635 }
636 }
637
638 for index in ddl.indexes.list_mut() {
639 rewrite_cow(&mut index.schema, from, to);
640 }
641
642 for fk in ddl.fks.list_mut() {
643 rewrite_cow(&mut fk.schema, from, to);
644 rewrite_cow(&mut fk.schema_to, from, to);
645 }
646
647 for pk in ddl.pks.list_mut() {
648 rewrite_cow(&mut pk.schema, from, to);
649 }
650
651 for unique in ddl.uniques.list_mut() {
652 rewrite_cow(&mut unique.schema, from, to);
653 }
654
655 for check in ddl.checks.list_mut() {
656 rewrite_cow(&mut check.schema, from, to);
657 }
658
659 for policy in ddl.policies.list_mut() {
660 rewrite_cow(&mut policy.schema, from, to);
661 }
662
663 for enum_ in ddl.enums.list_mut() {
664 rewrite_cow(&mut enum_.schema, from, to);
665 }
666
667 for sequence in ddl.sequences.list_mut() {
668 rewrite_cow(&mut sequence.schema, from, to);
669 }
670
671 for view in ddl.views.list_mut() {
672 rewrite_cow(&mut view.schema, from, to);
673 }
674}
675
676fn apply_table_rename(ddl: &mut PostgresDDL, schema: &str, from: &str, to: &str) {
677 for table in ddl.tables.list_mut() {
678 if table.schema.as_ref() == schema && table.name.as_ref() == from {
679 table.name = to.to_string().into();
680 }
681
682 if table.schema.as_ref() == schema
683 && let Some(inherits) = &mut table.inherits
684 {
685 if inherits.as_ref() == from {
686 *inherits = to.to_string().into();
687 } else if inherits.as_ref() == format!("{schema}.{from}") {
688 *inherits = format!("{schema}.{to}").into();
689 }
690 }
691 }
692
693 for column in ddl
694 .columns
695 .list_mut()
696 .iter_mut()
697 .filter(|column| column.schema.as_ref() == schema && column.table.as_ref() == from)
698 {
699 column.table = to.to_string().into();
700 }
701
702 for pk in ddl
703 .pks
704 .list_mut()
705 .iter_mut()
706 .filter(|pk| pk.schema.as_ref() == schema && pk.table.as_ref() == from)
707 {
708 pk.table = to.to_string().into();
709 }
710
711 for unique in ddl
712 .uniques
713 .list_mut()
714 .iter_mut()
715 .filter(|unique| unique.schema.as_ref() == schema && unique.table.as_ref() == from)
716 {
717 unique.table = to.to_string().into();
718 }
719
720 for check in ddl
721 .checks
722 .list_mut()
723 .iter_mut()
724 .filter(|check| check.schema.as_ref() == schema && check.table.as_ref() == from)
725 {
726 check.table = to.to_string().into();
727 }
728
729 for index in ddl
730 .indexes
731 .list_mut()
732 .iter_mut()
733 .filter(|index| index.schema.as_ref() == schema && index.table.as_ref() == from)
734 {
735 index.table = to.to_string().into();
736 }
737
738 for policy in ddl
739 .policies
740 .list_mut()
741 .iter_mut()
742 .filter(|policy| policy.schema.as_ref() == schema && policy.table.as_ref() == from)
743 {
744 policy.table = to.to_string().into();
745 }
746
747 for fk in ddl.fks.list_mut() {
748 if fk.schema.as_ref() == schema && fk.table.as_ref() == from {
749 fk.table = to.to_string().into();
750 }
751 if fk.schema_to.as_ref() == schema && fk.table_to.as_ref() == from {
752 fk.table_to = to.to_string().into();
753 }
754 }
755}
756
757fn apply_column_rename(ddl: &mut PostgresDDL, schema: &str, table: &str, from: &str, to: &str) {
758 let to = to.to_string();
759 for c in ddl.columns.list_mut().iter_mut() {
761 if c.schema.as_ref() == schema && c.table.as_ref() == table && c.name.as_ref() == from {
762 c.name = to.clone().into();
763 }
764 }
765
766 for pk in ddl
768 .pks
769 .list_mut()
770 .iter_mut()
771 .filter(|p| p.schema.as_ref() == schema && p.table.as_ref() == table)
772 {
773 for col in pk.columns.to_mut().iter_mut() {
774 if col.as_ref() == from {
775 *col = to.clone().into();
776 }
777 }
778 }
779
780 for u in ddl
782 .uniques
783 .list_mut()
784 .iter_mut()
785 .filter(|u| u.schema.as_ref() == schema && u.table.as_ref() == table)
786 {
787 for col in u.columns.to_mut().iter_mut() {
788 if col.as_ref() == from {
789 *col = to.clone().into();
790 }
791 }
792 }
793
794 for fk in ddl.fks.list_mut().iter_mut() {
796 if fk.schema.as_ref() == schema && fk.table.as_ref() == table {
797 for col in fk.columns.to_mut().iter_mut() {
798 if col.as_ref() == from {
799 *col = to.clone().into();
800 }
801 }
802 }
803 if fk.schema_to.as_ref() == schema && fk.table_to.as_ref() == table {
804 for col in fk.columns_to.to_mut().iter_mut() {
805 if col.as_ref() == from {
806 *col = to.clone().into();
807 }
808 }
809 }
810 }
811
812 for idx in ddl
814 .indexes
815 .list_mut()
816 .iter_mut()
817 .filter(|i| i.schema.as_ref() == schema && i.table.as_ref() == table)
818 {
819 for col in &mut idx.columns {
820 if !col.is_expression && col.value.as_ref() == from {
821 col.value = to.clone().into();
822 }
823 }
824 }
825}
826
827#[must_use]
829pub fn compute_migration_from_snapshots(
830 prev: &PostgresSnapshot,
831 cur: &PostgresSnapshot,
832) -> MigrationDiff {
833 let prev_ddl = PostgresDDL::from_entities(prev.ddl.clone());
834 let cur_ddl = PostgresDDL::from_entities(cur.ddl.clone());
835 compute_migration(&prev_ddl, &cur_ddl)
836}
837
838#[must_use]
840pub fn prepare_migration_renames(
841 schema_renames: &[SchemaRename],
842 table_renames: &[TableRename],
843 column_renames: &[ColumnRename],
844) -> Vec<String> {
845 let mut renames = Vec::new();
846
847 for sr in schema_renames {
848 renames.push(format!("schema:{}:{}", sr.from, sr.to));
849 }
850
851 for tr in table_renames {
852 renames.push(format!(
853 "table:{}.{}:{}.{}",
854 tr.schema, tr.from, tr.schema, tr.to
855 ));
856 }
857
858 for cr in column_renames {
859 renames.push(format!(
860 "column:{}.{}.{}:{}.{}.{}",
861 cr.schema, cr.table, cr.from, cr.schema, cr.table, cr.to
862 ));
863 }
864
865 renames
866}
867
868#[cfg(test)]
869mod tests {
870 use super::*;
871 use crate::postgres::collection::PostgresDDL;
872 use crate::postgres::ddl::{
873 Column, Enum, ForeignKey, Generated, GeneratedType, Index, IndexColumn, Policy, Schema,
874 Table,
875 };
876
877 fn postgres_table_with_id(schema: &str, table: &str) -> PostgresDDL {
878 let mut ddl = PostgresDDL::new();
879 ddl.schemas.push(Schema::new(schema.to_string()));
880 ddl.tables
881 .push(Table::new(schema.to_string(), table.to_string()));
882 ddl.columns
883 .push(Column::new(schema.to_string(), table.to_string(), "id", "integer").not_null());
884 ddl
885 }
886
887 #[test]
888 fn test_empty_diff() {
889 let prev = Vec::new();
890 let cur = Vec::new();
891
892 let diff = diff_snapshots(&prev, &cur);
893 assert!(!diff.has_changes());
894 }
895
896 #[test]
897 fn test_schema_creation() {
898 let prev = Vec::new();
899 let cur = vec![PostgresEntity::Schema(Schema::new("myschema"))];
900
901 let diff = diff_snapshots(&prev, &cur);
902 assert!(diff.has_changes());
903 assert_eq!(diff.created_schemas().len(), 1);
904 }
905
906 #[test]
907 fn test_table_creation() {
908 let prev = Vec::new();
909 let cur = vec![
910 PostgresEntity::Schema(Schema::new("public")),
911 PostgresEntity::Table(Table {
912 schema: "public".into(),
913 name: "users".into(),
914 is_unlogged: None,
915 is_temporary: None,
916 inherits: None,
917 tablespace: None,
918 is_rls_enabled: None,
919 comment: None,
920 }),
921 PostgresEntity::Column(Column {
922 schema: "public".into(),
923 table: "users".into(),
924 name: "id".into(),
925 sql_type: "integer".into(),
926 type_schema: None,
927 not_null: true,
928 default: None,
929 generated: None,
930 identity: None,
931 dimensions: None,
932 comment: None,
933 collate: None,
934 ordinal_position: None,
935 }),
936 ];
937
938 let diff = diff_snapshots(&prev, &cur);
939 assert!(diff.has_changes());
940 assert_eq!(diff.created_tables().len(), 1);
941 }
942
943 #[test]
944 fn pure_table_rename_emits_single_rename_statement() {
945 let prev = postgres_table_with_id("public", "users");
946 let cur = postgres_table_with_id("public", "accounts");
947
948 let migration = compute_migration(&prev, &cur);
949
950 assert_eq!(
951 migration.sql_statements,
952 vec!["ALTER TABLE \"users\" RENAME TO \"accounts\";"]
953 );
954 assert!(
955 !migration
956 .sql_statements
957 .iter()
958 .any(|statement| statement.starts_with("DROP TABLE"))
959 );
960 }
961
962 #[test]
963 fn table_rename_rewrites_indexes_foreign_keys_and_policies() {
964 let mut prev = postgres_table_with_id("public", "users");
965 prev.tables.push(Table::new("public", "posts"));
966 prev.columns
967 .push(Column::new("public", "posts", "id", "integer").not_null());
968 prev.columns
969 .push(Column::new("public", "posts", "user_id", "integer"));
970 prev.indexes.push(Index::new(
971 "public",
972 "users",
973 "idx_users_id",
974 vec![IndexColumn::new("id")],
975 ));
976 prev.fks.push(ForeignKey::from_strings(
977 "public".to_string(),
978 "posts".to_string(),
979 "fk_posts_user".to_string(),
980 vec!["user_id".to_string()],
981 "public".to_string(),
982 "users".to_string(),
983 vec!["id".to_string()],
984 ));
985 prev.policies
986 .push(Policy::new("public", "users", "users_policy"));
987
988 let mut cur = postgres_table_with_id("public", "accounts");
989 cur.tables.push(Table::new("public", "posts"));
990 cur.columns
991 .push(Column::new("public", "posts", "id", "integer").not_null());
992 cur.columns
993 .push(Column::new("public", "posts", "user_id", "integer"));
994 cur.indexes.push(Index::new(
995 "public",
996 "accounts",
997 "idx_users_id",
998 vec![IndexColumn::new("id")],
999 ));
1000 cur.fks.push(ForeignKey::from_strings(
1001 "public".to_string(),
1002 "posts".to_string(),
1003 "fk_posts_user".to_string(),
1004 vec!["user_id".to_string()],
1005 "public".to_string(),
1006 "accounts".to_string(),
1007 vec!["id".to_string()],
1008 ));
1009 cur.policies
1010 .push(Policy::new("public", "accounts", "users_policy"));
1011
1012 let migration = compute_migration(&prev, &cur);
1013
1014 assert_eq!(
1015 migration.sql_statements,
1016 vec!["ALTER TABLE \"users\" RENAME TO \"accounts\";"]
1017 );
1018 }
1019
1020 #[test]
1021 fn ambiguous_table_rename_does_not_guess_and_warns() {
1022 let mut prev = postgres_table_with_id("public", "users");
1023 let mut admins = postgres_table_with_id("public", "admins");
1024 admins.schemas.list_mut().clear();
1025 prev.tables.list_mut().append(admins.tables.list_mut());
1026 prev.columns.list_mut().append(admins.columns.list_mut());
1027
1028 let cur = postgres_table_with_id("public", "accounts");
1029
1030 let migration = compute_migration(&prev, &cur);
1031
1032 assert!(
1033 migration.warnings.iter().any(|warning| warning
1034 .contains("Ambiguous PostgreSQL table rename candidates")
1035 && warning.contains("rename_table_in")),
1036 "expected ambiguous rename warning, got {:?}",
1037 migration.warnings
1038 );
1039 assert!(
1040 !migration
1041 .sql_statements
1042 .iter()
1043 .any(|statement| statement.contains("RENAME TO"))
1044 );
1045 }
1046
1047 #[test]
1048 fn schema_rename_rekeys_tables_under_schema() {
1049 let prev = postgres_table_with_id("old_schema", "users");
1050 let cur = postgres_table_with_id("new_schema", "users");
1051
1052 let migration = compute_migration(&prev, &cur);
1053
1054 assert_eq!(
1055 migration.sql_statements,
1056 vec!["ALTER SCHEMA \"old_schema\" RENAME TO \"new_schema\";"]
1057 );
1058 assert!(
1059 !migration
1060 .sql_statements
1061 .iter()
1062 .any(|statement| statement.starts_with("DROP TABLE")
1063 || statement.starts_with("CREATE TABLE"))
1064 );
1065 }
1066
1067 #[test]
1068 fn column_rename_detected_across_type_and_default_spellings() {
1069 let mut prev = PostgresDDL::new();
1073 prev.tables.push(Table::new("public", "users"));
1074 let mut old_col = Column::new("public", "users", "full_name", "varchar(255)");
1075 old_col.default = Some("'anon'::character varying".into());
1076 prev.columns.push(old_col);
1077
1078 let mut cur = PostgresDDL::new();
1079 cur.tables.push(Table::new("public", "users"));
1080 let mut new_col = Column::new("public", "users", "display_name", "character varying(255)");
1081 new_col.default = Some("'anon'".into());
1082 cur.columns.push(new_col);
1083
1084 let migration = compute_migration(&prev, &cur);
1085 assert_eq!(
1086 migration.sql_statements,
1087 vec![
1088 "ALTER TABLE \"users\" RENAME COLUMN \"full_name\" TO \"display_name\";"
1089 .to_string()
1090 ]
1091 );
1092 }
1093
1094 #[test]
1095 fn table_rename_detected_across_type_alias_fingerprints() {
1096 let mut prev = PostgresDDL::new();
1098 prev.schemas.push(Schema::new("public"));
1099 prev.tables.push(Table::new("public", "users"));
1100 prev.columns
1101 .push(Column::new("public", "users", "id", "int4").not_null());
1102
1103 let mut cur = PostgresDDL::new();
1104 cur.schemas.push(Schema::new("public"));
1105 cur.tables.push(Table::new("public", "accounts"));
1106 cur.columns
1107 .push(Column::new("public", "accounts", "id", "INTEGER").not_null());
1108
1109 let migration = compute_migration(&prev, &cur);
1110 assert_eq!(
1111 migration.sql_statements,
1112 vec!["ALTER TABLE \"users\" RENAME TO \"accounts\";".to_string()]
1113 );
1114 }
1115
1116 #[test]
1117 fn test_column_not_null_change_generates_sql() {
1118 let mut prev_ddl = PostgresDDL::new();
1120 prev_ddl.tables.push(Table {
1121 schema: "public".into(),
1122 name: "users".into(),
1123 is_unlogged: None,
1124 is_temporary: None,
1125 inherits: None,
1126 tablespace: None,
1127 is_rls_enabled: None,
1128 comment: None,
1129 });
1130 prev_ddl.columns.push(Column {
1131 schema: "public".into(),
1132 table: "users".into(),
1133 name: "email".into(),
1134 sql_type: "text".into(),
1135 type_schema: None,
1136 not_null: false, default: None,
1138 generated: None,
1139 identity: None,
1140 dimensions: None,
1141 comment: None,
1142 collate: None,
1143 ordinal_position: None,
1144 });
1145
1146 let mut cur_ddl = PostgresDDL::new();
1147 cur_ddl.tables.push(Table {
1148 schema: "public".into(),
1149 name: "users".into(),
1150 is_unlogged: None,
1151 is_temporary: None,
1152 inherits: None,
1153 tablespace: None,
1154 is_rls_enabled: None,
1155 comment: None,
1156 });
1157 cur_ddl.columns.push(Column {
1158 schema: "public".into(),
1159 table: "users".into(),
1160 name: "email".into(),
1161 sql_type: "text".into(),
1162 type_schema: None,
1163 not_null: true, default: None,
1165 generated: None,
1166 identity: None,
1167 dimensions: None,
1168 comment: None,
1169 collate: None,
1170 ordinal_position: None,
1171 });
1172
1173 let migration = compute_migration(&prev_ddl, &cur_ddl);
1174
1175 assert!(
1177 !migration.sql_statements.is_empty(),
1178 "Should generate SQL statements"
1179 );
1180
1181 assert_eq!(migration.sql_statements.len(), 1);
1183 assert_eq!(
1184 migration.sql_statements[0],
1185 "ALTER TABLE \"users\" ALTER COLUMN \"email\" SET NOT NULL;"
1186 );
1187 }
1188
1189 #[test]
1190 fn test_column_type_change_generates_sql() {
1191 let mut prev_ddl = PostgresDDL::new();
1193 prev_ddl.tables.push(Table {
1194 schema: "public".into(),
1195 name: "users".into(),
1196 is_unlogged: None,
1197 is_temporary: None,
1198 inherits: None,
1199 tablespace: None,
1200 is_rls_enabled: None,
1201 comment: None,
1202 });
1203 prev_ddl.columns.push(Column {
1204 schema: "public".into(),
1205 table: "users".into(),
1206 name: "age".into(),
1207 sql_type: "text".into(), type_schema: None,
1209 not_null: false,
1210 default: None,
1211 generated: None,
1212 identity: None,
1213 dimensions: None,
1214 comment: None,
1215 collate: None,
1216 ordinal_position: None,
1217 });
1218
1219 let mut cur_ddl = PostgresDDL::new();
1220 cur_ddl.tables.push(Table {
1221 schema: "public".into(),
1222 name: "users".into(),
1223 is_unlogged: None,
1224 is_temporary: None,
1225 inherits: None,
1226 tablespace: None,
1227 is_rls_enabled: None,
1228 comment: None,
1229 });
1230 cur_ddl.columns.push(Column {
1231 schema: "public".into(),
1232 table: "users".into(),
1233 name: "age".into(),
1234 sql_type: "integer".into(), type_schema: None,
1236 not_null: false,
1237 default: None,
1238 generated: None,
1239 identity: None,
1240 dimensions: None,
1241 comment: None,
1242 collate: None,
1243 ordinal_position: None,
1244 });
1245
1246 let migration = compute_migration(&prev_ddl, &cur_ddl);
1247
1248 assert!(
1250 !migration.sql_statements.is_empty(),
1251 "Should generate SQL statements"
1252 );
1253
1254 assert_eq!(migration.sql_statements.len(), 1);
1256 assert_eq!(
1257 migration.sql_statements[0],
1258 "ALTER TABLE \"users\" ALTER COLUMN \"age\" SET DATA TYPE integer USING \"age\"::integer;"
1259 );
1260 }
1261
1262 #[test]
1263 fn test_column_default_change_generates_sql() {
1264 let mut prev_ddl = PostgresDDL::new();
1266 prev_ddl.tables.push(Table {
1267 schema: "public".into(),
1268 name: "users".into(),
1269 is_unlogged: None,
1270 is_temporary: None,
1271 inherits: None,
1272 tablespace: None,
1273 is_rls_enabled: None,
1274 comment: None,
1275 });
1276 prev_ddl.columns.push(Column {
1277 schema: "public".into(),
1278 table: "users".into(),
1279 name: "status".into(),
1280 sql_type: "text".into(),
1281 type_schema: None,
1282 not_null: false,
1283 default: None, generated: None,
1285 identity: None,
1286 dimensions: None,
1287 comment: None,
1288 collate: None,
1289 ordinal_position: None,
1290 });
1291
1292 let mut cur_ddl = PostgresDDL::new();
1293 cur_ddl.tables.push(Table {
1294 schema: "public".into(),
1295 name: "users".into(),
1296 is_unlogged: None,
1297 is_temporary: None,
1298 inherits: None,
1299 tablespace: None,
1300 is_rls_enabled: None,
1301 comment: None,
1302 });
1303 cur_ddl.columns.push(Column {
1304 schema: "public".into(),
1305 table: "users".into(),
1306 name: "status".into(),
1307 sql_type: "text".into(),
1308 type_schema: None,
1309 not_null: false,
1310 default: Some("'active'".into()), generated: None,
1312 identity: None,
1313 dimensions: None,
1314 comment: None,
1315 collate: None,
1316 ordinal_position: None,
1317 });
1318
1319 let migration = compute_migration(&prev_ddl, &cur_ddl);
1320
1321 assert!(
1323 !migration.sql_statements.is_empty(),
1324 "Should generate SQL statements"
1325 );
1326
1327 assert_eq!(migration.sql_statements.len(), 1);
1329 assert_eq!(
1330 migration.sql_statements[0],
1331 "ALTER TABLE \"users\" ALTER COLUMN \"status\" SET DEFAULT 'active';"
1332 );
1333 }
1334
1335 #[test]
1336 fn enum_value_removal_emits_warning() {
1337 let mut prev_ddl = PostgresDDL::new();
1338 prev_ddl.enums.push(Enum::from_strings(
1339 "public".to_string(),
1340 "status".to_string(),
1341 vec!["active".to_string(), "archived".to_string()],
1342 ));
1343
1344 let mut cur_ddl = PostgresDDL::new();
1345 cur_ddl.enums.push(Enum::from_strings(
1346 "public".to_string(),
1347 "status".to_string(),
1348 vec!["active".to_string()],
1349 ));
1350
1351 let migration = compute_migration(&prev_ddl, &cur_ddl);
1352 assert!(
1353 migration
1354 .warnings
1355 .iter()
1356 .any(|warning| warning.contains("cannot drop enum value")),
1357 "expected enum removal warning, got {:?}",
1358 migration.warnings
1359 );
1360 let drop_pos = migration
1363 .sql_statements
1364 .iter()
1365 .position(|statement| statement == "DROP TYPE \"status\";");
1366 let create_pos = migration
1367 .sql_statements
1368 .iter()
1369 .position(|statement| statement == "CREATE TYPE \"status\" AS ENUM ('active');");
1370 match (drop_pos, create_pos) {
1371 (Some(drop), Some(create)) => assert!(
1372 drop < create,
1373 "DROP TYPE must precede CREATE TYPE, got {:?}",
1374 migration.sql_statements
1375 ),
1376 _ => panic!(
1377 "expected enum recreate statements, got {:?}",
1378 migration.sql_statements
1379 ),
1380 }
1381 }
1382
1383 #[test]
1384 fn enum_mid_list_addition_uses_before_clause() {
1385 let mut prev_ddl = PostgresDDL::new();
1386 prev_ddl.enums.push(Enum::from_strings(
1387 "public".to_string(),
1388 "status".to_string(),
1389 vec!["active".to_string(), "archived".to_string()],
1390 ));
1391
1392 let mut cur_ddl = PostgresDDL::new();
1393 cur_ddl.enums.push(Enum::from_strings(
1394 "public".to_string(),
1395 "status".to_string(),
1396 vec![
1397 "active".to_string(),
1398 "pending".to_string(),
1399 "archived".to_string(),
1400 ],
1401 ));
1402
1403 let migration = compute_migration(&prev_ddl, &cur_ddl);
1404 assert_eq!(
1405 migration.sql_statements,
1406 vec!["ALTER TYPE \"status\" ADD VALUE 'pending' BEFORE 'archived';"]
1407 );
1408 }
1409
1410 #[test]
1411 fn adding_generated_expression_emits_data_loss_warning() {
1412 let mut prev_ddl = PostgresDDL::new();
1413 prev_ddl.tables.push(Table::new("public", "users"));
1414 prev_ddl
1415 .columns
1416 .push(Column::new("public", "users", "name_len", "integer"));
1417
1418 let mut cur_ddl = prev_ddl.clone();
1419 cur_ddl.columns.entities.clear();
1420 let mut generated = Column::new("public", "users", "name_len", "integer");
1421 generated.generated = Some(Generated {
1422 expression: "length(name)".into(),
1423 gen_type: GeneratedType::Stored,
1424 });
1425 cur_ddl.columns.push(generated);
1426
1427 let migration = compute_migration(&prev_ddl, &cur_ddl);
1428 assert!(
1429 migration
1430 .warnings
1431 .iter()
1432 .any(|warning| warning.contains("drops and recreates the column")),
1433 "expected generated column recreation warning, got {:?}",
1434 migration.warnings
1435 );
1436 }
1437}