1use vespertide_core::{MigrationAction, MigrationPlan, TableConstraint, TableDef};
2
3use crate::DatabaseBackend;
4use crate::error::QueryError;
5use crate::parallel_config::plan_query_par_action_threshold;
6use crate::sql::BuiltQuery;
7
8mod parallel;
9mod sequential;
10#[cfg(test)]
11mod test_support;
12mod transaction;
13
14#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
15pub struct PlanQueriesOptions {
16 pub wrap_in_transaction: bool,
18}
19
20#[derive(Debug)]
21pub struct PlanQueries {
22 pub action: MigrationAction,
23 pub postgres: Vec<BuiltQuery>,
24 pub mysql: Vec<BuiltQuery>,
25 pub sqlite: Vec<BuiltQuery>,
26}
27
28impl PlanQueries {
29 #[must_use]
36 pub fn into_transactional(mut queries: Vec<Self>) -> Vec<Self> {
37 transaction::wrap_backend_queries(&mut queries, DatabaseBackend::Postgres);
38 transaction::wrap_backend_queries(&mut queries, DatabaseBackend::MySql);
39 transaction::wrap_backend_queries(&mut queries, DatabaseBackend::Sqlite);
40 queries
41 }
42}
43
44fn action_target_table(action: &MigrationAction) -> Option<&str> {
47 match action {
48 MigrationAction::RenameTable { .. } | MigrationAction::RawSql { .. } => None,
49 _ => action.table_name(),
50 }
51}
52
53pub(super) fn pending_constraints_for_action(
62 plan: &MigrationPlan,
63 action_index: usize,
64 action: &MigrationAction,
65) -> Vec<TableConstraint> {
66 let Some(table) = action_target_table(action) else {
67 return vec![];
68 };
69
70 plan.actions[action_index + 1..]
71 .iter()
72 .filter_map(|a| {
73 if let MigrationAction::AddConstraint {
74 table: t,
75 constraint,
76 } = a
77 && t == table
78 && matches!(
79 constraint,
80 TableConstraint::Index { .. } | TableConstraint::Unique { .. }
81 )
82 {
83 Some(constraint.clone())
84 } else {
85 None
86 }
87 })
88 .collect()
89}
90
91pub fn build_plan_queries(
100 plan: &MigrationPlan,
101 current_schema: &[TableDef],
102) -> Result<Vec<PlanQueries>, QueryError> {
103 if plan.actions.len() < plan_query_par_action_threshold() {
104 return sequential::build_plan_queries_sequentially(plan, current_schema);
105 }
106
107 parallel::build_plan_queries_in_parallel(plan, current_schema)
108}
109
110pub fn build_plan_queries_with_options(
114 plan: &MigrationPlan,
115 current_schema: &[TableDef],
116 options: PlanQueriesOptions,
117) -> Result<Vec<PlanQueries>, QueryError> {
118 let queries = build_plan_queries(plan, current_schema)?;
119 if options.wrap_in_transaction {
120 Ok(PlanQueries::into_transactional(queries))
121 } else {
122 Ok(queries)
123 }
124}
125
126#[cfg(test)]
127mod tests {
128 use super::*;
129 use crate::sql::DatabaseBackend;
130 use crate::test_support::{backend_tag, col, joined_sql_semicolon};
131 use insta::{assert_snapshot, with_settings};
132 use rstest::rstest;
133 use vespertide_core::{
134 ColumnDef, ColumnType, MigrationAction, MigrationPlan, ReferenceAction, SimpleColumnType,
135 TableConstraint, TableDef,
136 };
137
138 #[rstest]
139 #[case::empty(
140 MigrationPlan {
141 id: String::new(),
142 comment: None,
143 created_at: None,
144 version: 1,
145 actions: vec![],
146 },
147 0
148 )]
149 #[case::single_action(
150 MigrationPlan {
151 id: String::new(),
152 comment: None,
153 created_at: None,
154 version: 1,
155 actions: vec![MigrationAction::DeleteTable {
156 table: "users".into(),
157 }],
158 },
159 1
160 )]
161 #[case::multiple_actions(
162 MigrationPlan {
163 id: String::new(),
164 comment: None,
165 created_at: None,
166 version: 1,
167 actions: vec![
168 MigrationAction::CreateTable {
169 table: "users".into(),
170 columns: vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
171 constraints: vec![],
172 },
173 MigrationAction::DeleteTable {
174 table: "posts".into(),
175 },
176 ],
177 },
178 2
179 )]
180 fn test_build_plan_queries(#[case] plan: MigrationPlan, #[case] expected_count: usize) {
181 let result = build_plan_queries(&plan, &[]).unwrap();
182 assert_eq!(
183 result.len(),
184 expected_count,
185 "Expected {} queries, got {}",
186 expected_count,
187 result.len()
188 );
189 }
190
191 #[rstest]
196 #[case::postgres("postgres", DatabaseBackend::Postgres)]
197 #[case::mysql("mysql", DatabaseBackend::MySql)]
198 #[case::sqlite("sqlite", DatabaseBackend::Sqlite)]
199 fn test_delete_column_after_create_table_with_inline_unique(
200 #[case] title: &str,
201 #[case] backend: DatabaseBackend,
202 ) {
203 let mut col_with_unique = col("gift_code", ColumnType::Simple(SimpleColumnType::Text));
204 col_with_unique.unique = Some(vespertide_core::StrOrBoolOrArray::Bool(true));
205
206 let plan = MigrationPlan {
207 id: String::new(),
208 comment: None,
209 created_at: None,
210 version: 1,
211 actions: vec![
212 MigrationAction::CreateTable {
213 table: "gift".into(),
214 columns: vec![
215 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
216 col_with_unique,
217 ],
218 constraints: vec![], },
220 MigrationAction::DeleteColumn {
221 table: "gift".into(),
222 column: "gift_code".into(),
223 },
224 ],
225 };
226
227 let result = build_plan_queries(&plan, &[]).unwrap();
228 let queries = match backend {
229 DatabaseBackend::Postgres => &result[1].postgres,
230 DatabaseBackend::MySql => &result[1].mysql,
231 DatabaseBackend::Sqlite => &result[1].sqlite,
232 };
233 let sql = joined_sql_semicolon(backend, queries);
234
235 with_settings!({ snapshot_path => "../snapshots", snapshot_suffix => format!("inline_unique_{}", title) }, {
236 assert_snapshot!(sql);
237 });
238 }
239
240 #[rstest]
242 #[case::postgres("postgres", DatabaseBackend::Postgres)]
243 #[case::mysql("mysql", DatabaseBackend::MySql)]
244 #[case::sqlite("sqlite", DatabaseBackend::Sqlite)]
245 fn test_delete_column_after_create_table_with_inline_index(
246 #[case] title: &str,
247 #[case] backend: DatabaseBackend,
248 ) {
249 let mut col_with_index = col("email", ColumnType::Simple(SimpleColumnType::Text));
250 col_with_index.index = Some(vespertide_core::StrOrBoolOrArray::Bool(true));
251
252 let plan = MigrationPlan {
253 id: String::new(),
254 comment: None,
255 created_at: None,
256 version: 1,
257 actions: vec![
258 MigrationAction::CreateTable {
259 table: "users".into(),
260 columns: vec![
261 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
262 col_with_index,
263 ],
264 constraints: vec![],
265 },
266 MigrationAction::DeleteColumn {
267 table: "users".into(),
268 column: "email".into(),
269 },
270 ],
271 };
272
273 let result = build_plan_queries(&plan, &[]).unwrap();
274 let queries = match backend {
275 DatabaseBackend::Postgres => &result[1].postgres,
276 DatabaseBackend::MySql => &result[1].mysql,
277 DatabaseBackend::Sqlite => &result[1].sqlite,
278 };
279 let sql = joined_sql_semicolon(backend, queries);
280
281 with_settings!({ snapshot_path => "../snapshots", snapshot_suffix => format!("inline_index_{}", title) }, {
282 assert_snapshot!(sql);
283 });
284 }
285
286 #[test]
287 fn test_build_plan_queries_sql_content() {
288 let plan = MigrationPlan {
289 id: String::new(),
290 comment: None,
291 created_at: None,
292 version: 1,
293 actions: vec![
294 MigrationAction::CreateTable {
295 table: "users".into(),
296 columns: vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
297 constraints: vec![],
298 },
299 MigrationAction::DeleteTable {
300 table: "posts".into(),
301 },
302 ],
303 };
304
305 let result = build_plan_queries(&plan, &[]).unwrap();
306 assert_eq!(result.len(), 2);
307
308 let sql1 = joined_sql_semicolon(DatabaseBackend::Postgres, &result[0].postgres);
310 assert!(sql1.contains("CREATE TABLE"));
311 assert!(sql1.contains("\"users\""));
312 assert!(sql1.contains("\"id\""));
313
314 let sql2 = joined_sql_semicolon(DatabaseBackend::Postgres, &result[1].postgres);
315 assert!(sql2.contains("DROP TABLE"));
316 assert!(sql2.contains("\"posts\""));
317
318 let sql1_mysql = joined_sql_semicolon(DatabaseBackend::MySql, &result[0].mysql);
320 assert!(sql1_mysql.contains("`users`"));
321
322 let sql2_mysql = joined_sql_semicolon(DatabaseBackend::MySql, &result[1].mysql);
323 assert!(sql2_mysql.contains("`posts`"));
324 }
325
326 #[test]
327 fn transactional_wrapping_leaves_empty_backend_queries_unchanged() {
328 let action = MigrationAction::RawSql {
329 sql: "-- noop".into(),
330 };
331 let queries = vec![PlanQueries {
332 action,
333 postgres: vec![],
334 mysql: vec![],
335 sqlite: vec![],
336 }];
337
338 let wrapped = PlanQueries::into_transactional(queries);
339
340 assert!(wrapped[0].postgres.is_empty());
341 assert!(wrapped[0].mysql.is_empty());
342 assert!(wrapped[0].sqlite.is_empty());
343 }
344
345 #[test]
346 fn transactional_wrapping_has_no_expect_on_last_non_empty_query() {
347 let source = include_str!("mod.rs");
348
349 assert!(!source.contains("expect(\"first non-empty backend query implies"));
350 }
351
352 fn fk_constraint() -> TableConstraint {
355 TableConstraint::ForeignKey {
356 name: None,
357 columns: vec!["category_id".into()],
358 ref_table: "category".into(),
359 ref_columns: vec!["id".into()],
360 on_delete: Some(ReferenceAction::Cascade),
361 on_update: None,
362 orphan_strategy: vespertide_core::ForeignKeyOrphanStrategy::default(),
363 }
364 }
365
366 fn unique_constraint() -> TableConstraint {
367 TableConstraint::Unique {
368 name: None,
369 columns: vec!["category_id".into()],
370 strategy: vespertide_core::UniqueConstraintStrategy::DeleteDuplicates {
371 keep: vespertide_core::KeepPolicy::First,
372 },
373 }
374 }
375
376 fn index_constraint() -> TableConstraint {
377 TableConstraint::Index {
378 name: None,
379 columns: vec!["category_id".into()],
380 }
381 }
382
383 fn plan_add_column_with_constraints(order: &[TableConstraint]) -> MigrationPlan {
385 let mut actions: Vec<MigrationAction> = vec![MigrationAction::AddColumn {
386 table: "product".into(),
387 column: Box::new(col(
388 "category_id",
389 ColumnType::Simple(SimpleColumnType::BigInt),
390 )),
391 fill_with: None,
392 }];
393 for c in order {
394 actions.push(MigrationAction::AddConstraint {
395 table: "product".into(),
396 constraint: c.clone(),
397 });
398 }
399 MigrationPlan {
400 id: String::new(),
401 comment: None,
402 created_at: None,
403 version: 1,
404 actions,
405 }
406 }
407
408 fn plan_remove_constraints_then_drop(order: &[TableConstraint]) -> MigrationPlan {
410 let mut actions: Vec<MigrationAction> = Vec::new();
411 for c in order {
412 actions.push(MigrationAction::RemoveConstraint {
413 table: "product".into(),
414 constraint: c.clone(),
415 });
416 }
417 actions.push(MigrationAction::DeleteColumn {
418 table: "product".into(),
419 column: "category_id".into(),
420 });
421 MigrationPlan {
422 id: String::new(),
423 comment: None,
424 created_at: None,
425 version: 1,
426 actions,
427 }
428 }
429
430 fn base_schema_no_constraints() -> Vec<TableDef> {
432 vec![TableDef {
433 name: "product".into(),
434 description: None,
435 columns: vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
436 constraints: vec![],
437 }]
438 }
439
440 fn base_schema_with_all_constraints() -> Vec<TableDef> {
442 vec![TableDef {
443 name: "product".into(),
444 description: None,
445 columns: vec![
446 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
447 col("category_id", ColumnType::Simple(SimpleColumnType::BigInt)),
448 ],
449 constraints: vec![fk_constraint(), unique_constraint(), index_constraint()],
450 }]
451 }
452
453 fn collect_all_sql(result: &[PlanQueries], backend: DatabaseBackend) -> String {
455 result
456 .iter()
457 .enumerate()
458 .map(|(i, pq)| {
459 let queries = match backend {
460 DatabaseBackend::Postgres => &pq.postgres,
461 DatabaseBackend::MySql => &pq.mysql,
462 DatabaseBackend::Sqlite => &pq.sqlite,
463 };
464 let sql = joined_sql_semicolon(backend, queries);
465 format!("-- Action {}: {:?}\n{}", i, pq.action, sql)
466 })
467 .collect::<Vec<_>>()
468 .join("\n\n")
469 }
470
471 fn assert_no_duplicate_indexes_per_action(result: &[PlanQueries]) {
476 for (i, pq) in result.iter().enumerate() {
477 let stmts: Vec<String> = pq
478 .sqlite
479 .iter()
480 .map(|q| q.build(DatabaseBackend::Sqlite))
481 .collect();
482
483 let index_stmts: Vec<&String> = stmts
484 .iter()
485 .filter(|s| s.contains("CREATE INDEX") || s.contains("CREATE UNIQUE INDEX"))
486 .collect();
487
488 let mut seen = std::collections::HashSet::new();
489 for stmt in &index_stmts {
490 assert!(
491 seen.insert(stmt.as_str()),
492 "Duplicate index within action {} ({:?}):\n {}\nAll index statements in this action:\n{}",
493 i,
494 pq.action,
495 stmt,
496 index_stmts
497 .iter()
498 .map(|s| format!(" {s}"))
499 .collect::<Vec<_>>()
500 .join("\n")
501 );
502 }
503 }
504 }
505
506 fn assert_no_orphan_duplicate_indexes(result: &[PlanQueries]) {
511 let mut live_indexes: std::collections::HashSet<String> = std::collections::HashSet::new();
514
515 for pq in result {
516 let stmts: Vec<String> = pq
517 .sqlite
518 .iter()
519 .map(|q| q.build(DatabaseBackend::Sqlite))
520 .collect();
521
522 if stmts.iter().any(|s| s.starts_with("DROP TABLE")) {
524 live_indexes.clear();
525 }
526
527 for stmt in &stmts {
528 if stmt.contains("CREATE INDEX") || stmt.contains("CREATE UNIQUE INDEX") {
529 assert!(
530 live_indexes.insert(stmt.clone()),
531 "Index would already exist when action {:?} tries to create it:\n {}\nCurrently live indexes:\n{}",
532 pq.action,
533 stmt,
534 live_indexes
535 .iter()
536 .map(|s| format!(" {s}"))
537 .collect::<Vec<_>>()
538 .join("\n")
539 );
540 }
541 }
542
543 for stmt in &stmts {
545 if stmt.starts_with("DROP INDEX") {
546 live_indexes.retain(|s| {
547 let drop_name = stmt
549 .strip_prefix("DROP INDEX \"")
550 .and_then(|s| s.strip_suffix('"'));
551 if let Some(name) = drop_name {
552 !s.contains(&format!("\"{name}\""))
553 } else {
554 true
555 }
556 });
557 }
558 }
559 }
560 }
561
562 #[rstest]
565 #[case::fk_unique_index("fk_uq_ix", &[fk_constraint(), unique_constraint(), index_constraint()])]
566 #[case::fk_index_unique("fk_ix_uq", &[fk_constraint(), index_constraint(), unique_constraint()])]
567 #[case::unique_fk_index("uq_fk_ix", &[unique_constraint(), fk_constraint(), index_constraint()])]
568 #[case::unique_index_fk("uq_ix_fk", &[unique_constraint(), index_constraint(), fk_constraint()])]
569 #[case::index_fk_unique("ix_fk_uq", &[index_constraint(), fk_constraint(), unique_constraint()])]
570 #[case::index_unique_fk("ix_uq_fk", &[index_constraint(), unique_constraint(), fk_constraint()])]
571 fn test_add_column_with_fk_unique_index_all_orderings(
572 #[case] title: &str,
573 #[case] order: &[TableConstraint],
574 ) {
575 let plan = plan_add_column_with_constraints(order);
576 let schema = base_schema_no_constraints();
577 let result = build_plan_queries(&plan, &schema).unwrap();
578
579 assert_no_duplicate_indexes_per_action(&result);
581 assert_no_orphan_duplicate_indexes(&result);
582
583 for backend in [
585 DatabaseBackend::Postgres,
586 DatabaseBackend::MySql,
587 DatabaseBackend::Sqlite,
588 ] {
589 let label = backend_tag(backend);
590 let sql = collect_all_sql(&result, backend);
591 with_settings!({ snapshot_path => "../snapshots", snapshot_suffix => format!("add_col_{}_{}", title, label) }, {
592 assert_snapshot!(sql);
593 });
594 }
595 }
596
597 #[rstest]
600 #[case::fk_unique_index("fk_uq_ix", &[fk_constraint(), unique_constraint(), index_constraint()])]
601 #[case::fk_index_unique("fk_ix_uq", &[fk_constraint(), index_constraint(), unique_constraint()])]
602 #[case::unique_fk_index("uq_fk_ix", &[unique_constraint(), fk_constraint(), index_constraint()])]
603 #[case::unique_index_fk("uq_ix_fk", &[unique_constraint(), index_constraint(), fk_constraint()])]
604 #[case::index_fk_unique("ix_fk_uq", &[index_constraint(), fk_constraint(), unique_constraint()])]
605 #[case::index_unique_fk("ix_uq_fk", &[index_constraint(), unique_constraint(), fk_constraint()])]
606 fn test_remove_fk_unique_index_then_drop_column_all_orderings(
607 #[case] title: &str,
608 #[case] order: &[TableConstraint],
609 ) {
610 let plan = plan_remove_constraints_then_drop(order);
611 let schema = base_schema_with_all_constraints();
612 let result = build_plan_queries(&plan, &schema).unwrap();
613
614 for backend in [
616 DatabaseBackend::Postgres,
617 DatabaseBackend::MySql,
618 DatabaseBackend::Sqlite,
619 ] {
620 let label = backend_tag(backend);
621 let sql = collect_all_sql(&result, backend);
622 with_settings!({ snapshot_path => "../snapshots", snapshot_suffix => format!("rm_col_{}_{}", title, label) }, {
623 assert_snapshot!(sql);
624 });
625 }
626 }
627
628 #[rstest]
631 #[case::fk_then_index("fk_ix", &[fk_constraint(), index_constraint()])]
632 #[case::index_then_fk("ix_fk", &[index_constraint(), fk_constraint()])]
633 fn test_add_column_with_fk_and_index_pair(
634 #[case] title: &str,
635 #[case] order: &[TableConstraint],
636 ) {
637 let plan = plan_add_column_with_constraints(order);
638 let schema = base_schema_no_constraints();
639 let result = build_plan_queries(&plan, &schema).unwrap();
640
641 assert_no_duplicate_indexes_per_action(&result);
642 assert_no_orphan_duplicate_indexes(&result);
643
644 for backend in [
645 DatabaseBackend::Postgres,
646 DatabaseBackend::MySql,
647 DatabaseBackend::Sqlite,
648 ] {
649 let label = backend_tag(backend);
650 let sql = collect_all_sql(&result, backend);
651 with_settings!({ snapshot_path => "../snapshots", snapshot_suffix => format!("add_col_pair_{}_{}", title, label) }, {
652 assert_snapshot!(sql);
653 });
654 }
655 }
656
657 #[rstest]
660 #[case::fk_then_unique("fk_uq", &[fk_constraint(), unique_constraint()])]
661 #[case::unique_then_fk("uq_fk", &[unique_constraint(), fk_constraint()])]
662 fn test_add_column_with_fk_and_unique_pair(
663 #[case] title: &str,
664 #[case] order: &[TableConstraint],
665 ) {
666 let plan = plan_add_column_with_constraints(order);
667 let schema = base_schema_no_constraints();
668 let result = build_plan_queries(&plan, &schema).unwrap();
669
670 assert_no_duplicate_indexes_per_action(&result);
671 assert_no_orphan_duplicate_indexes(&result);
672
673 for backend in [
674 DatabaseBackend::Postgres,
675 DatabaseBackend::MySql,
676 DatabaseBackend::Sqlite,
677 ] {
678 let label = backend_tag(backend);
679 let sql = collect_all_sql(&result, backend);
680 with_settings!({ snapshot_path => "../snapshots", snapshot_suffix => format!("add_col_pair_{}_{}", title, label) }, {
681 assert_snapshot!(sql);
682 });
683 }
684 }
685
686 #[rstest]
693 #[case::postgres("postgres", DatabaseBackend::Postgres)]
694 #[case::mysql("mysql", DatabaseBackend::MySql)]
695 #[case::sqlite("sqlite", DatabaseBackend::Sqlite)]
696 fn test_add_column_with_fk_no_duplicate_fk_in_temp_table(
697 #[case] label: &str,
698 #[case] backend: DatabaseBackend,
699 ) {
700 let schema = vec![
701 TableDef {
702 name: "project".into(),
703 description: None,
704 columns: vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
705 constraints: vec![],
706 },
707 TableDef {
708 name: "companion".into(),
709 description: None,
710 columns: vec![
711 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
712 col("user_id", ColumnType::Simple(SimpleColumnType::BigInt)),
713 ],
714 constraints: vec![
715 TableConstraint::ForeignKey {
716 name: None,
717 columns: vec!["user_id".into()],
718 ref_table: "user".into(),
719 ref_columns: vec!["id".into()],
720 on_delete: Some(ReferenceAction::Cascade),
721 on_update: None,
722 orphan_strategy: vespertide_core::ForeignKeyOrphanStrategy::default(),
723 },
724 TableConstraint::Unique {
725 name: Some("invite_code".into()),
726 columns: vec!["invite_code".into()],
727 strategy: vespertide_core::UniqueConstraintStrategy::DeleteDuplicates {
728 keep: vespertide_core::KeepPolicy::First,
729 },
730 },
731 TableConstraint::Index {
732 name: None,
733 columns: vec!["user_id".into()],
734 },
735 ],
736 },
737 ];
738
739 let plan = MigrationPlan {
740 id: String::new(),
741 comment: None,
742 created_at: None,
743 version: 1,
744 actions: vec![
745 MigrationAction::AddColumn {
746 table: "companion".into(),
747 column: Box::new(ColumnDef {
748 name: "project_id".into(),
749 r#type: ColumnType::Simple(SimpleColumnType::BigInt),
750 nullable: false,
751 default: None,
752 comment: None,
753 primary_key: None,
754 unique: None,
755 index: None,
756 foreign_key: Some(
757 vespertide_core::schema::foreign_key::ForeignKeySyntax::String(
758 "project.id".into(),
759 ),
760 ),
761 }),
762 fill_with: None,
763 },
764 MigrationAction::AddConstraint {
765 table: "companion".into(),
766 constraint: TableConstraint::ForeignKey {
767 name: None,
768 columns: vec!["project_id".into()],
769 ref_table: "project".into(),
770 ref_columns: vec!["id".into()],
771 on_delete: Some(ReferenceAction::Cascade),
772 on_update: None,
773 orphan_strategy: vespertide_core::ForeignKeyOrphanStrategy::default(),
774 },
775 },
776 MigrationAction::AddConstraint {
777 table: "companion".into(),
778 constraint: TableConstraint::Index {
779 name: None,
780 columns: vec!["project_id".into()],
781 },
782 },
783 ],
784 };
785
786 let result = build_plan_queries(&plan, &schema).unwrap();
787
788 assert_no_duplicate_indexes_per_action(&result);
789 assert_no_orphan_duplicate_indexes(&result);
790
791 let sql = collect_all_sql(&result, backend);
792 with_settings!({ snapshot_path => "../snapshots", snapshot_suffix => format!("dup_fk_{}", label) }, {
793 assert_snapshot!(sql);
794 });
795 }
796
797 #[rstest]
806 #[case::postgres("postgres", DatabaseBackend::Postgres)]
807 #[case::mysql("mysql", DatabaseBackend::MySql)]
808 #[case::sqlite("sqlite", DatabaseBackend::Sqlite)]
809 fn test_two_not_null_add_columns_with_inline_index_no_duplicate(
810 #[case] label: &str,
811 #[case] backend: DatabaseBackend,
812 ) {
813 use vespertide_core::DefaultValue;
814 use vespertide_core::schema::str_or_bool::StrOrBoolOrArray;
815
816 let schema = vec![TableDef {
817 name: "article".into(),
818 description: None,
819 columns: vec![
820 col("id", ColumnType::Simple(SimpleColumnType::Integer)),
821 col("title", ColumnType::Simple(SimpleColumnType::Text)),
822 ],
823 constraints: vec![],
824 }];
825
826 let plan = MigrationPlan {
827 id: String::new(),
828 comment: None,
829 created_at: None,
830 version: 1,
831 actions: vec![
832 MigrationAction::AddColumn {
834 table: "article".into(),
835 column: Box::new(ColumnDef {
836 name: "category_pinned".into(),
837 r#type: ColumnType::Simple(SimpleColumnType::Boolean),
838 nullable: false,
839 default: Some(DefaultValue::Bool(false)),
840 comment: None,
841 primary_key: None,
842 unique: None,
843 index: Some(StrOrBoolOrArray::Bool(true)),
844 foreign_key: None,
845 }),
846 fill_with: None,
847 },
848 MigrationAction::AddColumn {
850 table: "article".into(),
851 column: Box::new(ColumnDef {
852 name: "main_pinned".into(),
853 r#type: ColumnType::Simple(SimpleColumnType::Boolean),
854 nullable: false,
855 default: Some(DefaultValue::Bool(false)),
856 comment: None,
857 primary_key: None,
858 unique: None,
859 index: Some(StrOrBoolOrArray::Bool(true)),
860 foreign_key: None,
861 }),
862 fill_with: None,
863 },
864 MigrationAction::AddConstraint {
866 table: "article".into(),
867 constraint: TableConstraint::Index {
868 name: None,
869 columns: vec!["main_pinned".into()],
870 },
871 },
872 MigrationAction::AddConstraint {
874 table: "article".into(),
875 constraint: TableConstraint::Index {
876 name: None,
877 columns: vec!["category_pinned".into()],
878 },
879 },
880 ],
881 };
882
883 let result = build_plan_queries(&plan, &schema).unwrap();
884
885 assert_no_duplicate_indexes_per_action(&result);
887 assert_no_orphan_duplicate_indexes(&result);
888
889 let sql = collect_all_sql(&result, backend);
890 with_settings!({ snapshot_path => "../snapshots", snapshot_suffix => format!("two_not_null_inline_index_{}", label) }, {
891 assert_snapshot!(sql);
892 });
893 }
894}