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