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