Skip to main content

ormdantic_schema/
diff.rs

1use std::collections::BTreeMap;
2
3use ormdantic_core::{OrmdanticError, OrmdanticResult};
4
5use crate::{ColumnDef, ConstraintDef, IndexDef, NamespaceDef, SchemaDef, TableDef};
6
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub struct SchemaSnapshot {
9    schema: SchemaDef,
10}
11
12impl SchemaSnapshot {
13    pub fn new(schema: SchemaDef) -> Self {
14        Self { schema }
15    }
16
17    pub fn schema(&self) -> &SchemaDef {
18        &self.schema
19    }
20}
21
22#[derive(Debug, Clone, Default, PartialEq, Eq)]
23pub struct SchemaDiff {
24    operations: Vec<SchemaOperation>,
25}
26
27impl SchemaDiff {
28    pub fn new(operations: Vec<SchemaOperation>) -> Self {
29        Self { operations }
30    }
31
32    pub fn operations(&self) -> &[SchemaOperation] {
33        &self.operations
34    }
35
36    pub fn is_empty(&self) -> bool {
37        self.operations.is_empty()
38    }
39}
40
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub enum SchemaOperation {
43    CreateNamespace(NamespaceDef),
44    DropNamespace {
45        name: String,
46    },
47    SetNamespaceComment {
48        name: String,
49        comment: Option<String>,
50    },
51    CreateTable(TableDef),
52    DropTable {
53        name: String,
54    },
55    RecreateTable(TableDef),
56    AddColumn {
57        table: String,
58        column: ColumnDef,
59    },
60    DropColumn {
61        table: String,
62        column: String,
63    },
64    AlterColumn {
65        table: String,
66        column: ColumnDef,
67    },
68    SetColumnComment {
69        table: String,
70        column: ColumnDef,
71    },
72    CreateIndex {
73        table: String,
74        index: IndexDef,
75    },
76    DropIndex {
77        table: String,
78        name: String,
79    },
80    AddConstraint {
81        table: String,
82        constraint: ConstraintDef,
83    },
84    DropConstraint {
85        table: String,
86        name: String,
87    },
88    SetTableComment {
89        table: String,
90        comment: Option<String>,
91    },
92    SetTableTablespace {
93        table: String,
94        tablespace: Option<String>,
95    },
96    SetTableMysqlOptions {
97        table: String,
98        engine: Option<String>,
99        charset: Option<String>,
100        collation: Option<String>,
101        row_format: Option<String>,
102        key_block_size: Option<u32>,
103        pack_keys: Option<bool>,
104        checksum: Option<bool>,
105        delay_key_write: Option<bool>,
106        stats_persistent: Option<bool>,
107        stats_auto_recalc: Option<bool>,
108        stats_sample_pages: Option<u32>,
109        avg_row_length: Option<u32>,
110        max_rows: Option<u32>,
111        min_rows: Option<u32>,
112        insert_method: Option<String>,
113        data_directory: Option<String>,
114        index_directory: Option<String>,
115        connection: Option<String>,
116        union: Vec<String>,
117        partition_by: Option<String>,
118        partitions: Option<u32>,
119        subpartition_by: Option<String>,
120        subpartitions: Option<u32>,
121        auto_increment: Option<u32>,
122    },
123    SetTablePostgresInherits {
124        table: String,
125        add: Vec<String>,
126        drop: Vec<String>,
127    },
128    SetTablePostgresWith {
129        table: String,
130        set: Vec<(String, String)>,
131        reset: Vec<String>,
132    },
133    SetTablePostgresUsing {
134        table: String,
135        using: Option<String>,
136    },
137    SetTablePostgresUnlogged {
138        table: String,
139        unlogged: bool,
140    },
141    AttachPostgresPartition {
142        table: String,
143        parent: String,
144        bound: String,
145    },
146    DetachPostgresPartition {
147        table: String,
148        parent: String,
149    },
150}
151
152pub struct SchemaDiffer;
153
154impl SchemaDiffer {
155    pub fn diff(from: &SchemaSnapshot, to: &SchemaSnapshot) -> OrmdanticResult<SchemaDiff> {
156        let mut operations = Vec::new();
157        let from_namespaces = namespace_map(from.schema().namespaces())?;
158        let to_namespaces = namespace_map(to.schema().namespaces())?;
159        let from_tables = table_map(from.schema().tables())?;
160        let to_tables = table_map(to.schema().tables())?;
161        let mut namespace_drops = Vec::new();
162
163        for namespace in to.schema().namespaces() {
164            if !from_namespaces.contains_key(namespace.name()) {
165                operations.push(SchemaOperation::CreateNamespace(namespace.clone()));
166            }
167        }
168
169        for namespace in from.schema().namespaces() {
170            if !to_namespaces.contains_key(namespace.name()) {
171                namespace_drops.push(SchemaOperation::DropNamespace {
172                    name: namespace.name().to_string(),
173                });
174            }
175        }
176
177        for (name, from_namespace) in &from_namespaces {
178            let Some(to_namespace) = to_namespaces.get(name) else {
179                continue;
180            };
181            if from_namespace.comment() != to_namespace.comment() {
182                operations.push(SchemaOperation::SetNamespaceComment {
183                    name: (*name).to_string(),
184                    comment: to_namespace.comment().map(str::to_string),
185                });
186            }
187        }
188
189        for table in to.schema().tables() {
190            if !from_tables.contains_key(&table_key(table)) {
191                operations.push(SchemaOperation::CreateTable(table.clone()));
192            }
193        }
194
195        for table in from.schema().tables() {
196            if !to_tables.contains_key(&table_key(table)) {
197                operations.push(SchemaOperation::DropTable {
198                    name: table_key(table),
199                });
200            }
201        }
202
203        for (name, from_table) in &from_tables {
204            let Some(to_table) = to_tables.get(name) else {
205                continue;
206            };
207            if requires_table_recreate(from_table, to_table) {
208                operations.push(SchemaOperation::RecreateTable((*to_table).clone()));
209                continue;
210            }
211            diff_columns(&mut operations, from_table, to_table);
212            diff_indexes(&mut operations, from_table, to_table);
213            diff_constraints(&mut operations, from_table, to_table);
214            diff_table_metadata(&mut operations, from_table, to_table);
215        }
216
217        operations.extend(namespace_drops);
218        Ok(SchemaDiff::new(operations))
219    }
220}
221
222fn requires_table_recreate(from: &TableDef, to: &TableDef) -> bool {
223    from.postgres_partition_by() != to.postgres_partition_by()
224        || from.is_sqlite_strict() != to.is_sqlite_strict()
225        || from.is_sqlite_without_rowid() != to.is_sqlite_without_rowid()
226        || from.oracle_compress() != to.oracle_compress()
227}
228
229fn diff_table_metadata(operations: &mut Vec<SchemaOperation>, from: &TableDef, to: &TableDef) {
230    if from.comment() != to.comment() {
231        operations.push(SchemaOperation::SetTableComment {
232            table: table_key(to),
233            comment: to.comment().map(str::to_string),
234        });
235    }
236    if from.tablespace() != to.tablespace() {
237        operations.push(SchemaOperation::SetTableTablespace {
238            table: table_key(to),
239            tablespace: to.tablespace().map(str::to_string),
240        });
241    }
242    if from.mysql_engine() != to.mysql_engine()
243        || from.mysql_charset() != to.mysql_charset()
244        || from.mysql_collation() != to.mysql_collation()
245        || from.mysql_row_format() != to.mysql_row_format()
246        || from.mysql_key_block_size() != to.mysql_key_block_size()
247        || from.mysql_pack_keys() != to.mysql_pack_keys()
248        || from.mysql_checksum() != to.mysql_checksum()
249        || from.mysql_delay_key_write() != to.mysql_delay_key_write()
250        || from.mysql_stats_persistent() != to.mysql_stats_persistent()
251        || from.mysql_stats_auto_recalc() != to.mysql_stats_auto_recalc()
252        || from.mysql_stats_sample_pages() != to.mysql_stats_sample_pages()
253        || from.mysql_avg_row_length() != to.mysql_avg_row_length()
254        || from.mysql_max_rows() != to.mysql_max_rows()
255        || from.mysql_min_rows() != to.mysql_min_rows()
256        || from.mysql_insert_method() != to.mysql_insert_method()
257        || from.mysql_data_directory() != to.mysql_data_directory()
258        || from.mysql_index_directory() != to.mysql_index_directory()
259        || from.mysql_connection() != to.mysql_connection()
260        || from.mysql_union() != to.mysql_union()
261        || from.mysql_partition_by() != to.mysql_partition_by()
262        || from.mysql_partitions() != to.mysql_partitions()
263        || from.mysql_subpartition_by() != to.mysql_subpartition_by()
264        || from.mysql_subpartitions() != to.mysql_subpartitions()
265        || from.mysql_auto_increment() != to.mysql_auto_increment()
266    {
267        operations.push(SchemaOperation::SetTableMysqlOptions {
268            table: table_key(to),
269            engine: to.mysql_engine().map(str::to_string),
270            charset: to.mysql_charset().map(str::to_string),
271            collation: to.mysql_collation().map(str::to_string),
272            row_format: to.mysql_row_format().map(str::to_string),
273            key_block_size: to.mysql_key_block_size(),
274            pack_keys: to.mysql_pack_keys(),
275            checksum: to.mysql_checksum(),
276            delay_key_write: to.mysql_delay_key_write(),
277            stats_persistent: to.mysql_stats_persistent(),
278            stats_auto_recalc: to.mysql_stats_auto_recalc(),
279            stats_sample_pages: to.mysql_stats_sample_pages(),
280            avg_row_length: to.mysql_avg_row_length(),
281            max_rows: to.mysql_max_rows(),
282            min_rows: to.mysql_min_rows(),
283            insert_method: to.mysql_insert_method().map(str::to_string),
284            data_directory: to.mysql_data_directory().map(str::to_string),
285            index_directory: to.mysql_index_directory().map(str::to_string),
286            connection: to.mysql_connection().map(str::to_string),
287            union: to.mysql_union().to_vec(),
288            partition_by: to.mysql_partition_by().map(str::to_string),
289            partitions: to.mysql_partitions(),
290            subpartition_by: to.mysql_subpartition_by().map(str::to_string),
291            subpartitions: to.mysql_subpartitions(),
292            auto_increment: to.mysql_auto_increment(),
293        });
294    }
295    if from.postgres_inherits() != to.postgres_inherits() {
296        let add = to
297            .postgres_inherits()
298            .iter()
299            .filter(|parent| !from.postgres_inherits().contains(parent))
300            .cloned()
301            .collect::<Vec<_>>();
302        let drop = from
303            .postgres_inherits()
304            .iter()
305            .filter(|parent| !to.postgres_inherits().contains(parent))
306            .cloned()
307            .collect::<Vec<_>>();
308        operations.push(SchemaOperation::SetTablePostgresInherits {
309            table: table_key(to),
310            add,
311            drop,
312        });
313    }
314    if from.postgres_with() != to.postgres_with() {
315        let from_options = from
316            .postgres_with()
317            .iter()
318            .cloned()
319            .collect::<BTreeMap<_, _>>();
320        let to_options = to
321            .postgres_with()
322            .iter()
323            .cloned()
324            .collect::<BTreeMap<_, _>>();
325        let set = to_options
326            .iter()
327            .filter(|(name, value)| from_options.get(*name) != Some(*value))
328            .map(|(name, value)| (name.clone(), value.clone()))
329            .collect::<Vec<_>>();
330        let reset = from_options
331            .keys()
332            .filter(|name| !to_options.contains_key(*name))
333            .cloned()
334            .collect::<Vec<_>>();
335        operations.push(SchemaOperation::SetTablePostgresWith {
336            table: table_key(to),
337            set,
338            reset,
339        });
340    }
341    if from.postgres_using() != to.postgres_using() {
342        operations.push(SchemaOperation::SetTablePostgresUsing {
343            table: table_key(to),
344            using: to.postgres_using().map(str::to_string),
345        });
346    }
347    if from.is_postgres_unlogged() != to.is_postgres_unlogged() {
348        operations.push(SchemaOperation::SetTablePostgresUnlogged {
349            table: table_key(to),
350            unlogged: to.is_postgres_unlogged(),
351        });
352    }
353    if from.postgres_partition_of() != to.postgres_partition_of()
354        || from.postgres_partition_for() != to.postgres_partition_for()
355    {
356        if let Some(parent) = from.postgres_partition_of() {
357            operations.push(SchemaOperation::DetachPostgresPartition {
358                table: table_key(from),
359                parent: parent.to_string(),
360            });
361        }
362        if let (Some(parent), Some(bound)) =
363            (to.postgres_partition_of(), to.postgres_partition_for())
364        {
365            operations.push(SchemaOperation::AttachPostgresPartition {
366                table: table_key(to),
367                parent: parent.to_string(),
368                bound: bound.to_string(),
369            });
370        }
371    }
372}
373
374fn namespace_map(namespaces: &[NamespaceDef]) -> OrmdanticResult<BTreeMap<String, &NamespaceDef>> {
375    let mut map = BTreeMap::new();
376    for namespace in namespaces {
377        if map
378            .insert(namespace.name().to_string(), namespace)
379            .is_some()
380        {
381            return Err(OrmdanticError::SchemaDiffError {
382                message: format!(
383                    "duplicate namespace '{}' in schema snapshot",
384                    namespace.name()
385                ),
386            });
387        }
388    }
389    Ok(map)
390}
391
392fn table_map(tables: &[TableDef]) -> OrmdanticResult<BTreeMap<String, &TableDef>> {
393    let mut map = BTreeMap::new();
394    for table in tables {
395        let key = table_key(table);
396        if map.insert(key.clone(), table).is_some() {
397            return Err(OrmdanticError::SchemaDiffError {
398                message: format!("duplicate table '{key}' in schema snapshot"),
399            });
400        }
401    }
402    Ok(map)
403}
404
405fn table_key(table: &TableDef) -> String {
406    table.qualified_name().to_string()
407}
408
409fn diff_columns(operations: &mut Vec<SchemaOperation>, from: &TableDef, to: &TableDef) {
410    let from_columns = from
411        .columns()
412        .iter()
413        .map(|column| (column.name().to_string(), column))
414        .collect::<BTreeMap<_, _>>();
415    let to_columns = to
416        .columns()
417        .iter()
418        .map(|column| (column.name().to_string(), column))
419        .collect::<BTreeMap<_, _>>();
420
421    for column in to.columns() {
422        if !from_columns.contains_key(column.name()) {
423            operations.push(SchemaOperation::AddColumn {
424                table: table_key(to),
425                column: column.clone(),
426            });
427        }
428    }
429    for column in from.columns() {
430        if !to_columns.contains_key(column.name()) {
431            operations.push(SchemaOperation::DropColumn {
432                table: table_key(from),
433                column: column.name().to_string(),
434            });
435        }
436    }
437    for (name, from_column) in from_columns {
438        if let Some(to_column) = to_columns.get(&name) {
439            let to_column = *to_column;
440            if from_column == to_column {
441                continue;
442            }
443            if !from_column.definition_eq_ignoring_comment(to_column) {
444                operations.push(SchemaOperation::AlterColumn {
445                    table: table_key(to),
446                    column: to_column.clone(),
447                });
448            }
449            if from_column.comment() != to_column.comment() {
450                operations.push(SchemaOperation::SetColumnComment {
451                    table: table_key(to),
452                    column: to_column.clone(),
453                });
454            }
455        }
456    }
457}
458
459fn diff_indexes(operations: &mut Vec<SchemaOperation>, from: &TableDef, to: &TableDef) {
460    let from_indexes = from
461        .indexes()
462        .iter()
463        .map(|index| (index.name().to_string(), index))
464        .collect::<BTreeMap<_, _>>();
465    let to_indexes = to
466        .indexes()
467        .iter()
468        .map(|index| (index.name().to_string(), index))
469        .collect::<BTreeMap<_, _>>();
470
471    for index in to.indexes() {
472        if !from_indexes.contains_key(index.name()) {
473            operations.push(SchemaOperation::CreateIndex {
474                table: table_key(to),
475                index: index.clone(),
476            });
477        }
478    }
479    for index in from.indexes() {
480        if !to_indexes.contains_key(index.name()) {
481            operations.push(SchemaOperation::DropIndex {
482                table: table_key(from),
483                name: index.name().to_string(),
484            });
485        }
486    }
487    for (name, from_index) in from_indexes {
488        if let Some(to_index) = to_indexes.get(&name) {
489            if !indexes_equivalent(from_index, to_index) {
490                operations.push(SchemaOperation::DropIndex {
491                    table: table_key(from),
492                    name: name.clone(),
493                });
494                operations.push(SchemaOperation::CreateIndex {
495                    table: table_key(to),
496                    index: (*to_index).clone(),
497                });
498            }
499        }
500    }
501}
502
503fn indexes_equivalent(from: &IndexDef, to: &IndexDef) -> bool {
504    from.name() == to.name()
505        && from.columns() == to.columns()
506        && from.expressions_ref() == to.expressions_ref()
507        && from.is_unique() == to.is_unique()
508        && from.predicate() == to.predicate()
509        && from.include_columns_ref() == to.include_columns_ref()
510        && normalized_default_index_method(from.method_name())
511            == normalized_default_index_method(to.method_name())
512        && from.postgres_with_ref() == to.postgres_with_ref()
513}
514
515fn normalized_default_index_method(method: Option<&str>) -> Option<String> {
516    let normalized = method?.to_ascii_lowercase();
517    if normalized == "btree" {
518        None
519    } else {
520        Some(normalized)
521    }
522}
523
524fn diff_constraints(operations: &mut Vec<SchemaOperation>, from: &TableDef, to: &TableDef) {
525    let from_constraints = named_constraints(from);
526    let to_constraints = named_constraints(to);
527
528    for (name, constraint) in &to_constraints {
529        if !from_constraints.contains_key(name) {
530            operations.push(SchemaOperation::AddConstraint {
531                table: table_key(to),
532                constraint: constraint.clone(),
533            });
534        }
535    }
536    for name in from_constraints.keys() {
537        if !to_constraints.contains_key(name) {
538            operations.push(SchemaOperation::DropConstraint {
539                table: table_key(from),
540                name: name.clone(),
541            });
542        }
543    }
544    for (name, from_constraint) in from_constraints {
545        if let Some(to_constraint) = to_constraints.get(&name) {
546            if &from_constraint != to_constraint {
547                operations.push(SchemaOperation::DropConstraint {
548                    table: table_key(from),
549                    name: name.clone(),
550                });
551                operations.push(SchemaOperation::AddConstraint {
552                    table: table_key(to),
553                    constraint: to_constraint.clone(),
554                });
555            }
556        }
557    }
558}
559
560fn named_constraints(table: &TableDef) -> BTreeMap<String, ConstraintDef> {
561    let mut constraints = BTreeMap::new();
562    for constraint in table.unique_constraints() {
563        constraints.insert(
564            constraint.name().to_string(),
565            ConstraintDef::Unique(constraint.clone()),
566        );
567    }
568    for constraint in table.check_constraints() {
569        if let Some(name) = constraint.name() {
570            constraints.insert(name.to_string(), ConstraintDef::Check(constraint.clone()));
571        }
572    }
573    for constraint in table.foreign_keys() {
574        if let Some(name) = constraint.name() {
575            constraints.insert(
576                name.to_string(),
577                ConstraintDef::ForeignKey(constraint.clone()),
578            );
579        }
580    }
581    for constraint in table.exclusion_constraints() {
582        constraints.insert(
583            constraint.name().to_string(),
584            ConstraintDef::Exclusion(constraint.clone()),
585        );
586    }
587    constraints
588}