1use std::collections::HashMap;
2
3use vespertide_core::{MigrationAction, MigrationPlan, TableDef};
4
5use crate::error::PlannerError;
6
7pub fn diff_schemas(from: &[TableDef], to: &[TableDef]) -> Result<MigrationPlan, PlannerError> {
11 let mut actions: Vec<MigrationAction> = Vec::new();
12
13 let from_normalized: Vec<TableDef> = from
15 .iter()
16 .map(|t| {
17 t.normalize().map_err(|e| {
18 PlannerError::TableValidation(format!(
19 "Failed to normalize table '{}': {}",
20 t.name, e
21 ))
22 })
23 })
24 .collect::<Result<Vec<_>, _>>()?;
25 let to_normalized: Vec<TableDef> = to
26 .iter()
27 .map(|t| {
28 t.normalize().map_err(|e| {
29 PlannerError::TableValidation(format!(
30 "Failed to normalize table '{}': {}",
31 t.name, e
32 ))
33 })
34 })
35 .collect::<Result<Vec<_>, _>>()?;
36
37 let from_map: HashMap<_, _> = from_normalized
38 .iter()
39 .map(|t| (t.name.as_str(), t))
40 .collect();
41 let to_map: HashMap<_, _> = to_normalized.iter().map(|t| (t.name.as_str(), t)).collect();
42
43 for name in from_map.keys() {
45 if !to_map.contains_key(name) {
46 actions.push(MigrationAction::DeleteTable {
47 table: (*name).to_string(),
48 });
49 }
50 }
51
52 for (name, to_tbl) in &to_map {
54 if let Some(from_tbl) = from_map.get(name) {
55 let from_cols: HashMap<_, _> = from_tbl
57 .columns
58 .iter()
59 .map(|c| (c.name.as_str(), c))
60 .collect();
61 let to_cols: HashMap<_, _> = to_tbl
62 .columns
63 .iter()
64 .map(|c| (c.name.as_str(), c))
65 .collect();
66
67 for col in from_cols.keys() {
69 if !to_cols.contains_key(col) {
70 actions.push(MigrationAction::DeleteColumn {
71 table: (*name).to_string(),
72 column: (*col).to_string(),
73 });
74 }
75 }
76
77 for (col, to_def) in &to_cols {
79 if let Some(from_def) = from_cols.get(col)
80 && from_def.r#type != to_def.r#type
81 {
82 actions.push(MigrationAction::ModifyColumnType {
83 table: (*name).to_string(),
84 column: (*col).to_string(),
85 new_type: to_def.r#type.clone(),
86 });
87 }
88 }
89
90 for (col, def) in &to_cols {
92 if !from_cols.contains_key(col) {
93 actions.push(MigrationAction::AddColumn {
94 table: (*name).to_string(),
95 column: (*def).clone(),
96 fill_with: None,
97 });
98 }
99 }
100
101 let from_indexes: HashMap<_, _> = from_tbl
103 .indexes
104 .iter()
105 .map(|i| (i.name.as_str(), i))
106 .collect();
107 let to_indexes: HashMap<_, _> = to_tbl
108 .indexes
109 .iter()
110 .map(|i| (i.name.as_str(), i))
111 .collect();
112
113 for idx in from_indexes.keys() {
114 if !to_indexes.contains_key(idx) {
115 actions.push(MigrationAction::RemoveIndex {
116 table: (*name).to_string(),
117 name: (*idx).to_string(),
118 });
119 }
120 }
121 for (idx, def) in &to_indexes {
122 if !from_indexes.contains_key(idx) {
123 actions.push(MigrationAction::AddIndex {
124 table: (*name).to_string(),
125 index: (*def).clone(),
126 });
127 }
128 }
129
130 for from_constraint in &from_tbl.constraints {
132 if !to_tbl.constraints.contains(from_constraint) {
133 actions.push(MigrationAction::RemoveConstraint {
134 table: (*name).to_string(),
135 constraint: from_constraint.clone(),
136 });
137 }
138 }
139 for to_constraint in &to_tbl.constraints {
140 if !from_tbl.constraints.contains(to_constraint) {
141 actions.push(MigrationAction::AddConstraint {
142 table: (*name).to_string(),
143 constraint: to_constraint.clone(),
144 });
145 }
146 }
147 }
148 }
149
150 for (name, tbl) in &to_map {
152 if !from_map.contains_key(name) {
153 actions.push(MigrationAction::CreateTable {
154 table: tbl.name.clone(),
155 columns: tbl.columns.clone(),
156 constraints: tbl.constraints.clone(),
157 });
158 for idx in &tbl.indexes {
159 actions.push(MigrationAction::AddIndex {
160 table: tbl.name.clone(),
161 index: idx.clone(),
162 });
163 }
164 }
165 }
166
167 Ok(MigrationPlan {
168 comment: None,
169 created_at: None,
170 version: 0,
171 actions,
172 })
173}
174
175#[cfg(test)]
176mod tests {
177 use super::*;
178 use rstest::rstest;
179 use vespertide_core::{ColumnDef, ColumnType, IndexDef, MigrationAction, SimpleColumnType};
180
181 fn col(name: &str, ty: ColumnType) -> ColumnDef {
182 ColumnDef {
183 name: name.to_string(),
184 r#type: ty,
185 nullable: true,
186 default: None,
187 comment: None,
188 primary_key: None,
189 unique: None,
190 index: None,
191 foreign_key: None,
192 }
193 }
194
195 fn table(
196 name: &str,
197 columns: Vec<ColumnDef>,
198 constraints: Vec<vespertide_core::TableConstraint>,
199 indexes: Vec<IndexDef>,
200 ) -> TableDef {
201 TableDef {
202 name: name.to_string(),
203 columns,
204 constraints,
205 indexes,
206 }
207 }
208
209 #[rstest]
210 #[case::add_column_and_index(
211 vec![table(
212 "users",
213 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
214 vec![],
215 vec![],
216 )],
217 vec![table(
218 "users",
219 vec![
220 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
221 col("name", ColumnType::Simple(SimpleColumnType::Text)),
222 ],
223 vec![],
224 vec![IndexDef {
225 name: "idx_users_name".into(),
226 columns: vec!["name".into()],
227 unique: false,
228 }],
229 )],
230 vec![
231 MigrationAction::AddColumn {
232 table: "users".into(),
233 column: col("name", ColumnType::Simple(SimpleColumnType::Text)),
234 fill_with: None,
235 },
236 MigrationAction::AddIndex {
237 table: "users".into(),
238 index: IndexDef {
239 name: "idx_users_name".into(),
240 columns: vec!["name".into()],
241 unique: false,
242 },
243 },
244 ]
245 )]
246 #[case::drop_table(
247 vec![table(
248 "users",
249 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
250 vec![],
251 vec![],
252 )],
253 vec![],
254 vec![MigrationAction::DeleteTable {
255 table: "users".into()
256 }]
257 )]
258 #[case::add_table(
259 vec![],
260 vec![table(
261 "users",
262 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
263 vec![],
264 vec![IndexDef {
265 name: "idx_users_id".into(),
266 columns: vec!["id".into()],
267 unique: true,
268 }],
269 )],
270 vec![
271 MigrationAction::CreateTable {
272 table: "users".into(),
273 columns: vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
274 constraints: vec![],
275 },
276 MigrationAction::AddIndex {
277 table: "users".into(),
278 index: IndexDef {
279 name: "idx_users_id".into(),
280 columns: vec!["id".into()],
281 unique: true,
282 },
283 },
284 ]
285 )]
286 #[case::delete_column(
287 vec![table(
288 "users",
289 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer)), col("name", ColumnType::Simple(SimpleColumnType::Text))],
290 vec![],
291 vec![],
292 )],
293 vec![table(
294 "users",
295 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
296 vec![],
297 vec![],
298 )],
299 vec![MigrationAction::DeleteColumn {
300 table: "users".into(),
301 column: "name".into(),
302 }]
303 )]
304 #[case::modify_column_type(
305 vec![table(
306 "users",
307 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
308 vec![],
309 vec![],
310 )],
311 vec![table(
312 "users",
313 vec![col("id", ColumnType::Simple(SimpleColumnType::Text))],
314 vec![],
315 vec![],
316 )],
317 vec![MigrationAction::ModifyColumnType {
318 table: "users".into(),
319 column: "id".into(),
320 new_type: ColumnType::Simple(SimpleColumnType::Text),
321 }]
322 )]
323 #[case::remove_index(
324 vec![table(
325 "users",
326 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
327 vec![],
328 vec![IndexDef {
329 name: "idx_users_id".into(),
330 columns: vec!["id".into()],
331 unique: false,
332 }],
333 )],
334 vec![table(
335 "users",
336 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
337 vec![],
338 vec![],
339 )],
340 vec![MigrationAction::RemoveIndex {
341 table: "users".into(),
342 name: "idx_users_id".into(),
343 }]
344 )]
345 #[case::add_index_existing_table(
346 vec![table(
347 "users",
348 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
349 vec![],
350 vec![],
351 )],
352 vec![table(
353 "users",
354 vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
355 vec![],
356 vec![IndexDef {
357 name: "idx_users_id".into(),
358 columns: vec!["id".into()],
359 unique: true,
360 }],
361 )],
362 vec![MigrationAction::AddIndex {
363 table: "users".into(),
364 index: IndexDef {
365 name: "idx_users_id".into(),
366 columns: vec!["id".into()],
367 unique: true,
368 },
369 }]
370 )]
371 fn diff_schemas_detects_additions(
372 #[case] from_schema: Vec<TableDef>,
373 #[case] to_schema: Vec<TableDef>,
374 #[case] expected_actions: Vec<MigrationAction>,
375 ) {
376 let plan = diff_schemas(&from_schema, &to_schema).unwrap();
377 assert_eq!(plan.actions, expected_actions);
378 }
379
380 mod inline_constraints {
382 use super::*;
383 use vespertide_core::schema::foreign_key::ForeignKeyDef;
384 use vespertide_core::{StrOrBoolOrArray, TableConstraint};
385
386 fn col_with_pk(name: &str, ty: ColumnType) -> ColumnDef {
387 ColumnDef {
388 name: name.to_string(),
389 r#type: ty,
390 nullable: false,
391 default: None,
392 comment: None,
393 primary_key: Some(true),
394 unique: None,
395 index: None,
396 foreign_key: None,
397 }
398 }
399
400 fn col_with_unique(name: &str, ty: ColumnType) -> ColumnDef {
401 ColumnDef {
402 name: name.to_string(),
403 r#type: ty,
404 nullable: true,
405 default: None,
406 comment: None,
407 primary_key: None,
408 unique: Some(StrOrBoolOrArray::Bool(true)),
409 index: None,
410 foreign_key: None,
411 }
412 }
413
414 fn col_with_index(name: &str, ty: ColumnType) -> ColumnDef {
415 ColumnDef {
416 name: name.to_string(),
417 r#type: ty,
418 nullable: true,
419 default: None,
420 comment: None,
421 primary_key: None,
422 unique: None,
423 index: Some(StrOrBoolOrArray::Bool(true)),
424 foreign_key: None,
425 }
426 }
427
428 fn col_with_fk(name: &str, ty: ColumnType, ref_table: &str, ref_col: &str) -> ColumnDef {
429 ColumnDef {
430 name: name.to_string(),
431 r#type: ty,
432 nullable: true,
433 default: None,
434 comment: None,
435 primary_key: None,
436 unique: None,
437 index: None,
438 foreign_key: Some(ForeignKeyDef {
439 ref_table: ref_table.to_string(),
440 ref_columns: vec![ref_col.to_string()],
441 on_delete: None,
442 on_update: None,
443 }),
444 }
445 }
446
447 #[test]
448 fn create_table_with_inline_pk() {
449 let plan = diff_schemas(
450 &[],
451 &[table(
452 "users",
453 vec![
454 col_with_pk("id", ColumnType::Simple(SimpleColumnType::Integer)),
455 col("name", ColumnType::Simple(SimpleColumnType::Text)),
456 ],
457 vec![],
458 vec![],
459 )],
460 )
461 .unwrap();
462
463 assert_eq!(plan.actions.len(), 1);
464 if let MigrationAction::CreateTable { constraints, .. } = &plan.actions[0] {
465 assert_eq!(constraints.len(), 1);
466 assert!(matches!(
467 &constraints[0],
468 TableConstraint::PrimaryKey { columns } if columns == &["id".to_string()]
469 ));
470 } else {
471 panic!("Expected CreateTable action");
472 }
473 }
474
475 #[test]
476 fn create_table_with_inline_unique() {
477 let plan = diff_schemas(
478 &[],
479 &[table(
480 "users",
481 vec![
482 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
483 col_with_unique("email", ColumnType::Simple(SimpleColumnType::Text)),
484 ],
485 vec![],
486 vec![],
487 )],
488 )
489 .unwrap();
490
491 assert_eq!(plan.actions.len(), 1);
492 if let MigrationAction::CreateTable { constraints, .. } = &plan.actions[0] {
493 assert_eq!(constraints.len(), 1);
494 assert!(matches!(
495 &constraints[0],
496 TableConstraint::Unique { name: None, columns } if columns == &["email".to_string()]
497 ));
498 } else {
499 panic!("Expected CreateTable action");
500 }
501 }
502
503 #[test]
504 fn create_table_with_inline_index() {
505 let plan = diff_schemas(
506 &[],
507 &[table(
508 "users",
509 vec![
510 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
511 col_with_index("name", ColumnType::Simple(SimpleColumnType::Text)),
512 ],
513 vec![],
514 vec![],
515 )],
516 )
517 .unwrap();
518
519 assert_eq!(plan.actions.len(), 2);
521 assert!(matches!(
522 &plan.actions[0],
523 MigrationAction::CreateTable { .. }
524 ));
525 if let MigrationAction::AddIndex { index, .. } = &plan.actions[1] {
526 assert_eq!(index.name, "idx_users_name");
527 assert_eq!(index.columns, vec!["name".to_string()]);
528 } else {
529 panic!("Expected AddIndex action");
530 }
531 }
532
533 #[test]
534 fn create_table_with_inline_fk() {
535 let plan = diff_schemas(
536 &[],
537 &[table(
538 "posts",
539 vec![
540 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
541 col_with_fk(
542 "user_id",
543 ColumnType::Simple(SimpleColumnType::Integer),
544 "users",
545 "id",
546 ),
547 ],
548 vec![],
549 vec![],
550 )],
551 )
552 .unwrap();
553
554 assert_eq!(plan.actions.len(), 1);
555 if let MigrationAction::CreateTable { constraints, .. } = &plan.actions[0] {
556 assert_eq!(constraints.len(), 1);
557 assert!(matches!(
558 &constraints[0],
559 TableConstraint::ForeignKey { columns, ref_table, ref_columns, .. }
560 if columns == &["user_id".to_string()]
561 && ref_table == "users"
562 && ref_columns == &["id".to_string()]
563 ));
564 } else {
565 panic!("Expected CreateTable action");
566 }
567 }
568
569 #[test]
570 fn add_index_via_inline_constraint() {
571 let plan = diff_schemas(
573 &[table(
574 "users",
575 vec![
576 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
577 col("name", ColumnType::Simple(SimpleColumnType::Text)),
578 ],
579 vec![],
580 vec![],
581 )],
582 &[table(
583 "users",
584 vec![
585 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
586 col_with_index("name", ColumnType::Simple(SimpleColumnType::Text)),
587 ],
588 vec![],
589 vec![],
590 )],
591 )
592 .unwrap();
593
594 assert_eq!(plan.actions.len(), 1);
595 if let MigrationAction::AddIndex { table, index } = &plan.actions[0] {
596 assert_eq!(table, "users");
597 assert_eq!(index.name, "idx_users_name");
598 assert_eq!(index.columns, vec!["name".to_string()]);
599 } else {
600 panic!("Expected AddIndex action, got {:?}", plan.actions[0]);
601 }
602 }
603
604 #[test]
605 fn create_table_with_all_inline_constraints() {
606 let mut id_col = col("id", ColumnType::Simple(SimpleColumnType::Integer));
607 id_col.primary_key = Some(true);
608 id_col.nullable = false;
609
610 let mut email_col = col("email", ColumnType::Simple(SimpleColumnType::Text));
611 email_col.unique = Some(StrOrBoolOrArray::Bool(true));
612
613 let mut name_col = col("name", ColumnType::Simple(SimpleColumnType::Text));
614 name_col.index = Some(StrOrBoolOrArray::Bool(true));
615
616 let mut org_id_col = col("org_id", ColumnType::Simple(SimpleColumnType::Integer));
617 org_id_col.foreign_key = Some(ForeignKeyDef {
618 ref_table: "orgs".into(),
619 ref_columns: vec!["id".into()],
620 on_delete: None,
621 on_update: None,
622 });
623
624 let plan = diff_schemas(
625 &[],
626 &[table(
627 "users",
628 vec![id_col, email_col, name_col, org_id_col],
629 vec![],
630 vec![],
631 )],
632 )
633 .unwrap();
634
635 assert_eq!(plan.actions.len(), 2);
637
638 if let MigrationAction::CreateTable { constraints, .. } = &plan.actions[0] {
639 assert_eq!(constraints.len(), 3);
641 } else {
642 panic!("Expected CreateTable action");
643 }
644
645 assert!(matches!(&plan.actions[1], MigrationAction::AddIndex { .. }));
647 }
648
649 #[test]
650 fn add_constraint_to_existing_table() {
651 let from_schema = vec![table(
653 "users",
654 vec![
655 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
656 col("email", ColumnType::Simple(SimpleColumnType::Text)),
657 ],
658 vec![],
659 vec![],
660 )];
661
662 let to_schema = vec![table(
663 "users",
664 vec![
665 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
666 col("email", ColumnType::Simple(SimpleColumnType::Text)),
667 ],
668 vec![vespertide_core::TableConstraint::Unique {
669 name: Some("uq_users_email".into()),
670 columns: vec!["email".into()],
671 }],
672 vec![],
673 )];
674
675 let plan = diff_schemas(&from_schema, &to_schema).unwrap();
676 assert_eq!(plan.actions.len(), 1);
677 if let MigrationAction::AddConstraint { table, constraint } = &plan.actions[0] {
678 assert_eq!(table, "users");
679 assert!(matches!(
680 constraint,
681 vespertide_core::TableConstraint::Unique { name: Some(n), columns }
682 if n == "uq_users_email" && columns == &vec!["email".to_string()]
683 ));
684 } else {
685 panic!("Expected AddConstraint action, got {:?}", plan.actions[0]);
686 }
687 }
688
689 #[test]
690 fn remove_constraint_from_existing_table() {
691 let from_schema = vec![table(
693 "users",
694 vec![
695 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
696 col("email", ColumnType::Simple(SimpleColumnType::Text)),
697 ],
698 vec![vespertide_core::TableConstraint::Unique {
699 name: Some("uq_users_email".into()),
700 columns: vec!["email".into()],
701 }],
702 vec![],
703 )];
704
705 let to_schema = vec![table(
706 "users",
707 vec![
708 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
709 col("email", ColumnType::Simple(SimpleColumnType::Text)),
710 ],
711 vec![],
712 vec![],
713 )];
714
715 let plan = diff_schemas(&from_schema, &to_schema).unwrap();
716 assert_eq!(plan.actions.len(), 1);
717 if let MigrationAction::RemoveConstraint { table, constraint } = &plan.actions[0] {
718 assert_eq!(table, "users");
719 assert!(matches!(
720 constraint,
721 vespertide_core::TableConstraint::Unique { name: Some(n), columns }
722 if n == "uq_users_email" && columns == &vec!["email".to_string()]
723 ));
724 } else {
725 panic!(
726 "Expected RemoveConstraint action, got {:?}",
727 plan.actions[0]
728 );
729 }
730 }
731
732 #[test]
733 fn diff_schemas_with_normalize_error() {
734 let mut col1 = col("col1", ColumnType::Simple(SimpleColumnType::Text));
736 col1.index = Some(StrOrBoolOrArray::Str("idx1".into()));
737
738 let table = TableDef {
739 name: "test".into(),
740 columns: vec![
741 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
742 col1.clone(),
743 {
744 let mut c = col1.clone();
746 c.index = Some(StrOrBoolOrArray::Str("idx1".into()));
747 c
748 },
749 ],
750 constraints: vec![],
751 indexes: vec![],
752 };
753
754 let result = diff_schemas(&[], &[table]);
755 assert!(result.is_err());
756 if let Err(PlannerError::TableValidation(msg)) = result {
757 assert!(msg.contains("Failed to normalize table"));
758 assert!(msg.contains("Duplicate index"));
759 } else {
760 panic!("Expected TableValidation error, got {:?}", result);
761 }
762 }
763
764 #[test]
765 fn diff_schemas_with_normalize_error_in_from_schema() {
766 let mut col1 = col("col1", ColumnType::Simple(SimpleColumnType::Text));
768 col1.index = Some(StrOrBoolOrArray::Str("idx1".into()));
769
770 let table = TableDef {
771 name: "test".into(),
772 columns: vec![
773 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
774 col1.clone(),
775 {
776 let mut c = col1.clone();
778 c.index = Some(StrOrBoolOrArray::Str("idx1".into()));
779 c
780 },
781 ],
782 constraints: vec![],
783 indexes: vec![],
784 };
785
786 let result = diff_schemas(&[table], &[]);
788 assert!(result.is_err());
789 if let Err(PlannerError::TableValidation(msg)) = result {
790 assert!(msg.contains("Failed to normalize table"));
791 assert!(msg.contains("Duplicate index"));
792 } else {
793 panic!("Expected TableValidation error, got {:?}", result);
794 }
795 }
796 }
797}