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