Skip to main content

drizzle_migrations/postgres/
diff.rs

1//! Schema diff types and logic for `PostgreSQL`
2//!
3//! This module provides diffing between `PostgreSQL` DDL collections and
4//! generates migration statements from schema changes.
5
6use 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/// Complete schema diff between two `PostgreSQL` snapshots
15#[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    /// Get created entities
32    #[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    /// Get dropped entities
41    #[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    /// Get altered entities
50    #[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    /// Get diffs filtered by entity kind
59    #[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    /// Get created tables
65    #[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    /// Get dropped tables
74    #[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    /// Get created schemas
83    #[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    /// Get created enums
92    #[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/// Compare two `PostgreSQL` snapshots
102#[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/// Compare two `PostgreSQL` DDL collections directly
112#[must_use]
113pub fn diff_collections(prev: &PostgresDDL, cur: &PostgresDDL) -> SchemaDiff {
114    SchemaDiff {
115        diffs: diff_ddl(prev, cur),
116    }
117}
118
119/// Compare two full `PostgreSQL` snapshots
120#[must_use]
121pub fn diff_full_snapshots(prev: &PostgresSnapshot, cur: &PostgresSnapshot) -> SchemaDiff {
122    diff_snapshots(&prev.ddl, &cur.ddl)
123}
124
125// =============================================================================
126// Migration Diff Result
127// =============================================================================
128
129/// A schema rename operation
130#[derive(Debug, Clone)]
131pub struct SchemaRename {
132    pub from: String,
133    pub to: String,
134}
135
136/// A table rename operation
137#[derive(Debug, Clone)]
138pub struct TableRename {
139    pub schema: String,
140    pub from: String,
141    pub to: String,
142}
143
144/// A column rename operation
145#[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/// Result of computing a migration diff
154#[derive(Debug, Clone, Default)]
155pub struct MigrationDiff {
156    /// Generated SQL statements
157    pub sql_statements: Vec<String>,
158    /// Renames that occurred (for tracking in snapshot)
159    pub renames: Vec<String>,
160    /// Warning messages
161    pub warnings: Vec<String>,
162}
163
164/// Compute a full migration diff between two `PostgreSQL` DDL states
165#[must_use]
166pub fn compute_migration(prev: &PostgresDDL, cur: &PostgresDDL) -> MigrationDiff {
167    // Heuristic rename detection (non-interactive):
168    // - detect exact schema/table renames before normal diffing
169    // - detect simple column renames: one dropped + one created column in the same table
170    //   with identical column properties (type/nullability/default/etc).
171    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            // Use the diff engine's normalization so `int4` vs `INTEGER`
337            // spellings fingerprint identically and renames are detected
338            // instead of degrading to DROP + CREATE.
339            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            // Use the diff engine's equivalence (type aliases, default-cast
576            // stripping) so `int4` vs `INTEGER` or `'x'::text` vs `'x'`
577            // spellings still register as a rename.
578            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    // Columns
760    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    // PKs
767    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    // Uniques
781    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    // FKs (both table side and referenced side)
795    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    // Indexes
813    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/// Compute a migration from snapshots
828#[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/// Prepare rename tracking strings for snapshot storage
839#[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        // Introspected side: udt names + cast defaults. Schema side:
1070        // canonical spellings. The rename must still be detected instead of
1071        // degrading to DROP + ADD.
1072        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        // int4 vs INTEGER fingerprints must match for rename detection.
1097        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        // Test that changing nullable to not null generates ALTER COLUMN SQL
1119        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, // nullable
1137            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, // not null
1164            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        // Should have generated SQL statements
1176        assert!(
1177            !migration.sql_statements.is_empty(),
1178            "Should generate SQL statements"
1179        );
1180
1181        // Check the SQL contains ALTER COLUMN SET NOT NULL
1182        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        // Test that changing column type generates ALTER COLUMN SQL
1192        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(), // text
1208            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(), // integer
1235            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        // Should have generated SQL statements
1249        assert!(
1250            !migration.sql_statements.is_empty(),
1251            "Should generate SQL statements"
1252        );
1253
1254        // Check the SQL contains ALTER COLUMN SET DATA TYPE with USING cast
1255        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        // Test that changing column default generates ALTER COLUMN SQL
1265        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, // no default
1284            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()), // has default
1311            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        // Should have generated SQL statements
1322        assert!(
1323            !migration.sql_statements.is_empty(),
1324            "Should generate SQL statements"
1325        );
1326
1327        // Check the SQL contains ALTER COLUMN SET DEFAULT
1328        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        // The removal is handled by recreating the type; each command is its
1361        // own statement so drivers can run them through prepared statements.
1362        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}