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}