Skip to main content

vespertide_query/
builder.rs

1use vespertide_core::{MigrationAction, MigrationPlan, TableDef};
2use vespertide_planner::apply_action;
3
4use crate::DatabaseBackend;
5use crate::error::QueryError;
6use crate::sql::BuiltQuery;
7use crate::sql::build_action_queries_with_pending;
8
9pub struct PlanQueries {
10    pub action: MigrationAction,
11    pub postgres: Vec<BuiltQuery>,
12    pub mysql: Vec<BuiltQuery>,
13    pub sqlite: Vec<BuiltQuery>,
14}
15
16pub fn build_plan_queries(
17    plan: &MigrationPlan,
18    current_schema: &[TableDef],
19) -> Result<Vec<PlanQueries>, QueryError> {
20    let mut queries: Vec<PlanQueries> = Vec::new();
21    // Clone the schema so we can mutate it as we apply actions
22    let mut evolving_schema = current_schema.to_vec();
23
24    for (i, action) in plan.actions.iter().enumerate() {
25        // For SQLite: collect pending AddConstraint Index/Unique actions for the same table.
26        // These constraints may exist in the logical schema (from AddColumn normalization)
27        // but haven't been physically created as DB indexes yet.
28        // Without this, a temp table rebuild would recreate these indexes prematurely,
29        // causing "index already exists" errors when their AddConstraint actions run later.
30        let pending_constraints: Vec<vespertide_core::TableConstraint> =
31            if let MigrationAction::AddConstraint { table, .. } = action {
32                plan.actions[i + 1..]
33                    .iter()
34                    .filter_map(|a| {
35                        if let MigrationAction::AddConstraint {
36                            table: t,
37                            constraint,
38                        } = a
39                        {
40                            if t == table
41                                && matches!(
42                                    constraint,
43                                    vespertide_core::TableConstraint::Index { .. }
44                                        | vespertide_core::TableConstraint::Unique { .. }
45                                )
46                            {
47                                Some(constraint.clone())
48                            } else {
49                                None
50                            }
51                        } else {
52                            None
53                        }
54                    })
55                    .collect()
56            } else {
57                vec![]
58            };
59
60        // Build queries with the current state of the schema
61        let postgres_queries = build_action_queries_with_pending(
62            &DatabaseBackend::Postgres,
63            action,
64            &evolving_schema,
65            &pending_constraints,
66        )?;
67        let mysql_queries = build_action_queries_with_pending(
68            &DatabaseBackend::MySql,
69            action,
70            &evolving_schema,
71            &pending_constraints,
72        )?;
73        let sqlite_queries = build_action_queries_with_pending(
74            &DatabaseBackend::Sqlite,
75            action,
76            &evolving_schema,
77            &pending_constraints,
78        )?;
79        queries.push(PlanQueries {
80            action: action.clone(),
81            postgres: postgres_queries,
82            mysql: mysql_queries,
83            sqlite: sqlite_queries,
84        });
85
86        // Apply the action to update the schema for the next iteration
87        // Note: We ignore errors here because some actions (like DeleteTable) may reference
88        // tables that don't exist in the provided current_schema. This is OK for SQL generation
89        // purposes - we still generate the correct SQL, and the schema evolution is best-effort.
90        let _ = apply_action(&mut evolving_schema, action);
91    }
92    Ok(queries)
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98    use crate::sql::DatabaseBackend;
99    use insta::{assert_snapshot, with_settings};
100    use rstest::rstest;
101    use vespertide_core::{
102        ColumnDef, ColumnType, MigrationAction, MigrationPlan, SimpleColumnType,
103    };
104
105    fn col(name: &str, ty: ColumnType) -> ColumnDef {
106        ColumnDef {
107            name: name.to_string(),
108            r#type: ty,
109            nullable: true,
110            default: None,
111            comment: None,
112            primary_key: None,
113            unique: None,
114            index: None,
115            foreign_key: None,
116        }
117    }
118
119    #[rstest]
120    #[case::empty(
121        MigrationPlan {
122            id: String::new(),
123            comment: None,
124            created_at: None,
125            version: 1,
126            actions: vec![],
127        },
128        0
129    )]
130    #[case::single_action(
131        MigrationPlan {
132            id: String::new(),
133            comment: None,
134            created_at: None,
135            version: 1,
136            actions: vec![MigrationAction::DeleteTable {
137                table: "users".into(),
138            }],
139        },
140        1
141    )]
142    #[case::multiple_actions(
143        MigrationPlan {
144            id: String::new(),
145            comment: None,
146            created_at: None,
147            version: 1,
148            actions: vec![
149                MigrationAction::CreateTable {
150                    table: "users".into(),
151                    columns: vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
152                    constraints: vec![],
153                },
154                MigrationAction::DeleteTable {
155                    table: "posts".into(),
156                },
157            ],
158        },
159        2
160    )]
161    fn test_build_plan_queries(#[case] plan: MigrationPlan, #[case] expected_count: usize) {
162        let result = build_plan_queries(&plan, &[]).unwrap();
163        assert_eq!(
164            result.len(),
165            expected_count,
166            "Expected {} queries, got {}",
167            expected_count,
168            result.len()
169        );
170    }
171
172    fn build_sql_snapshot(result: &[BuiltQuery], backend: DatabaseBackend) -> String {
173        result
174            .iter()
175            .map(|q| q.build(backend))
176            .collect::<Vec<_>>()
177            .join(";\n")
178    }
179
180    /// Regression test: SQLite must emit DROP INDEX before DROP COLUMN when
181    /// the column was created with inline `unique: true` (no explicit table constraint).
182    /// Previously, apply_action didn't normalize inline constraints, so the evolving
183    /// schema had empty constraints and SQLite's DROP COLUMN failed.
184    #[rstest]
185    #[case::postgres("postgres", DatabaseBackend::Postgres)]
186    #[case::mysql("mysql", DatabaseBackend::MySql)]
187    #[case::sqlite("sqlite", DatabaseBackend::Sqlite)]
188    fn test_delete_column_after_create_table_with_inline_unique(
189        #[case] title: &str,
190        #[case] backend: DatabaseBackend,
191    ) {
192        let mut col_with_unique = col("gift_code", ColumnType::Simple(SimpleColumnType::Text));
193        col_with_unique.unique = Some(vespertide_core::StrOrBoolOrArray::Bool(true));
194
195        let plan = MigrationPlan {
196            id: String::new(),
197            comment: None,
198            created_at: None,
199            version: 1,
200            actions: vec![
201                MigrationAction::CreateTable {
202                    table: "gift".into(),
203                    columns: vec![
204                        col("id", ColumnType::Simple(SimpleColumnType::Integer)),
205                        col_with_unique,
206                    ],
207                    constraints: vec![], // No explicit constraints - only inline unique: true
208                },
209                MigrationAction::DeleteColumn {
210                    table: "gift".into(),
211                    column: "gift_code".into(),
212                },
213            ],
214        };
215
216        let result = build_plan_queries(&plan, &[]).unwrap();
217        let queries = match backend {
218            DatabaseBackend::Postgres => &result[1].postgres,
219            DatabaseBackend::MySql => &result[1].mysql,
220            DatabaseBackend::Sqlite => &result[1].sqlite,
221        };
222        let sql = build_sql_snapshot(queries, backend);
223
224        with_settings!({ snapshot_suffix => format!("inline_unique_{}", title) }, {
225            assert_snapshot!(sql);
226        });
227    }
228
229    /// Same regression test for inline `index: true`.
230    #[rstest]
231    #[case::postgres("postgres", DatabaseBackend::Postgres)]
232    #[case::mysql("mysql", DatabaseBackend::MySql)]
233    #[case::sqlite("sqlite", DatabaseBackend::Sqlite)]
234    fn test_delete_column_after_create_table_with_inline_index(
235        #[case] title: &str,
236        #[case] backend: DatabaseBackend,
237    ) {
238        let mut col_with_index = col("email", ColumnType::Simple(SimpleColumnType::Text));
239        col_with_index.index = Some(vespertide_core::StrOrBoolOrArray::Bool(true));
240
241        let plan = MigrationPlan {
242            id: String::new(),
243            comment: None,
244            created_at: None,
245            version: 1,
246            actions: vec![
247                MigrationAction::CreateTable {
248                    table: "users".into(),
249                    columns: vec![
250                        col("id", ColumnType::Simple(SimpleColumnType::Integer)),
251                        col_with_index,
252                    ],
253                    constraints: vec![],
254                },
255                MigrationAction::DeleteColumn {
256                    table: "users".into(),
257                    column: "email".into(),
258                },
259            ],
260        };
261
262        let result = build_plan_queries(&plan, &[]).unwrap();
263        let queries = match backend {
264            DatabaseBackend::Postgres => &result[1].postgres,
265            DatabaseBackend::MySql => &result[1].mysql,
266            DatabaseBackend::Sqlite => &result[1].sqlite,
267        };
268        let sql = build_sql_snapshot(queries, backend);
269
270        with_settings!({ snapshot_suffix => format!("inline_index_{}", title) }, {
271            assert_snapshot!(sql);
272        });
273    }
274
275    #[test]
276    fn test_build_plan_queries_sql_content() {
277        let plan = MigrationPlan {
278            id: String::new(),
279            comment: None,
280            created_at: None,
281            version: 1,
282            actions: vec![
283                MigrationAction::CreateTable {
284                    table: "users".into(),
285                    columns: vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
286                    constraints: vec![],
287                },
288                MigrationAction::DeleteTable {
289                    table: "posts".into(),
290                },
291            ],
292        };
293
294        let result = build_plan_queries(&plan, &[]).unwrap();
295        assert_eq!(result.len(), 2);
296
297        // Test PostgreSQL output
298        let sql1 = result[0]
299            .postgres
300            .iter()
301            .map(|q| q.build(DatabaseBackend::Postgres))
302            .collect::<Vec<_>>()
303            .join(";\n");
304        assert!(sql1.contains("CREATE TABLE"));
305        assert!(sql1.contains("\"users\""));
306        assert!(sql1.contains("\"id\""));
307
308        let sql2 = result[1]
309            .postgres
310            .iter()
311            .map(|q| q.build(DatabaseBackend::Postgres))
312            .collect::<Vec<_>>()
313            .join(";\n");
314        assert!(sql2.contains("DROP TABLE"));
315        assert!(sql2.contains("\"posts\""));
316
317        // Test MySQL output
318        let sql1_mysql = result[0]
319            .mysql
320            .iter()
321            .map(|q| q.build(DatabaseBackend::MySql))
322            .collect::<Vec<_>>()
323            .join(";\n");
324        assert!(sql1_mysql.contains("`users`"));
325
326        let sql2_mysql = result[1]
327            .mysql
328            .iter()
329            .map(|q| q.build(DatabaseBackend::MySql))
330            .collect::<Vec<_>>()
331            .join(";\n");
332        assert!(sql2_mysql.contains("`posts`"));
333    }
334
335    // ── Helpers for constraint migration tests ──────────────────────────
336
337    use vespertide_core::{ReferenceAction, TableConstraint};
338
339    fn fk_constraint() -> TableConstraint {
340        TableConstraint::ForeignKey {
341            name: None,
342            columns: vec!["category_id".into()],
343            ref_table: "category".into(),
344            ref_columns: vec!["id".into()],
345            on_delete: Some(ReferenceAction::Cascade),
346            on_update: None,
347        }
348    }
349
350    fn unique_constraint() -> TableConstraint {
351        TableConstraint::Unique {
352            name: None,
353            columns: vec!["category_id".into()],
354        }
355    }
356
357    fn index_constraint() -> TableConstraint {
358        TableConstraint::Index {
359            name: None,
360            columns: vec!["category_id".into()],
361        }
362    }
363
364    /// Build a plan that adds a column then adds constraints in the given order.
365    fn plan_add_column_with_constraints(order: &[TableConstraint]) -> MigrationPlan {
366        let mut actions: Vec<MigrationAction> = vec![MigrationAction::AddColumn {
367            table: "product".into(),
368            column: Box::new(col(
369                "category_id",
370                ColumnType::Simple(SimpleColumnType::BigInt),
371            )),
372            fill_with: None,
373        }];
374        for c in order {
375            actions.push(MigrationAction::AddConstraint {
376                table: "product".into(),
377                constraint: c.clone(),
378            });
379        }
380        MigrationPlan {
381            id: String::new(),
382            comment: None,
383            created_at: None,
384            version: 1,
385            actions,
386        }
387    }
388
389    /// Build a plan that removes constraints in the given order then drops the column.
390    fn plan_remove_constraints_then_drop(order: &[TableConstraint]) -> MigrationPlan {
391        let mut actions: Vec<MigrationAction> = Vec::new();
392        for c in order {
393            actions.push(MigrationAction::RemoveConstraint {
394                table: "product".into(),
395                constraint: c.clone(),
396            });
397        }
398        actions.push(MigrationAction::DeleteColumn {
399            table: "product".into(),
400            column: "category_id".into(),
401        });
402        MigrationPlan {
403            id: String::new(),
404            comment: None,
405            created_at: None,
406            version: 1,
407            actions,
408        }
409    }
410
411    /// Schema with an existing table that has NO constraints on category_id (for add tests).
412    fn base_schema_no_constraints() -> Vec<TableDef> {
413        vec![TableDef {
414            name: "product".into(),
415            description: None,
416            columns: vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
417            constraints: vec![],
418        }]
419    }
420
421    /// Schema with an existing table that HAS FK + Unique + Index on category_id (for remove tests).
422    fn base_schema_with_all_constraints() -> Vec<TableDef> {
423        vec![TableDef {
424            name: "product".into(),
425            description: None,
426            columns: vec![
427                col("id", ColumnType::Simple(SimpleColumnType::Integer)),
428                col("category_id", ColumnType::Simple(SimpleColumnType::BigInt)),
429            ],
430            constraints: vec![fk_constraint(), unique_constraint(), index_constraint()],
431        }]
432    }
433
434    /// Collect ALL SQL statements from a plan result for a given backend.
435    fn collect_all_sql(result: &[PlanQueries], backend: DatabaseBackend) -> String {
436        result
437            .iter()
438            .enumerate()
439            .map(|(i, pq)| {
440                let queries = match backend {
441                    DatabaseBackend::Postgres => &pq.postgres,
442                    DatabaseBackend::MySql => &pq.mysql,
443                    DatabaseBackend::Sqlite => &pq.sqlite,
444                };
445                let sql = build_sql_snapshot(queries, backend);
446                format!("-- Action {}: {:?}\n{}", i, pq.action, sql)
447            })
448            .collect::<Vec<_>>()
449            .join("\n\n")
450    }
451
452    /// Assert no duplicate CREATE INDEX / CREATE UNIQUE INDEX within a single
453    /// action's SQLite output. Cross-action duplicates are allowed because a
454    /// temp table rebuild (DROP + RENAME) legitimately destroys and recreates
455    /// indexes that a prior action already created.
456    fn assert_no_duplicate_indexes_per_action(result: &[PlanQueries]) {
457        for (i, pq) in result.iter().enumerate() {
458            let stmts: Vec<String> = pq
459                .sqlite
460                .iter()
461                .map(|q| q.build(DatabaseBackend::Sqlite))
462                .collect();
463
464            let index_stmts: Vec<&String> = stmts
465                .iter()
466                .filter(|s| s.contains("CREATE INDEX") || s.contains("CREATE UNIQUE INDEX"))
467                .collect();
468
469            let mut seen = std::collections::HashSet::new();
470            for stmt in &index_stmts {
471                assert!(
472                    seen.insert(stmt.as_str()),
473                    "Duplicate index within action {} ({:?}):\n  {}\nAll index statements in this action:\n{}",
474                    i,
475                    pq.action,
476                    stmt,
477                    index_stmts
478                        .iter()
479                        .map(|s| format!("  {}", s))
480                        .collect::<Vec<_>>()
481                        .join("\n")
482                );
483            }
484        }
485    }
486
487    /// Assert that no AddConstraint Index/Unique action produces an index that
488    /// was already recreated by a preceding temp-table rebuild within the same plan.
489    /// This catches the original bug: FK temp-table rebuild creating an index that
490    /// a later AddConstraint INDEX also creates (without DROP TABLE in between).
491    fn assert_no_orphan_duplicate_indexes(result: &[PlanQueries]) {
492        // Track indexes that exist after each action.
493        // A DROP TABLE resets the set; CREATE INDEX adds to it.
494        let mut live_indexes: std::collections::HashSet<String> = std::collections::HashSet::new();
495
496        for pq in result {
497            let stmts: Vec<String> = pq
498                .sqlite
499                .iter()
500                .map(|q| q.build(DatabaseBackend::Sqlite))
501                .collect();
502
503            // If this action does a DROP TABLE, all indexes are destroyed
504            if stmts.iter().any(|s| s.starts_with("DROP TABLE")) {
505                live_indexes.clear();
506            }
507
508            for stmt in &stmts {
509                if stmt.contains("CREATE INDEX") || stmt.contains("CREATE UNIQUE INDEX") {
510                    assert!(
511                        live_indexes.insert(stmt.clone()),
512                        "Index would already exist when action {:?} tries to create it:\n  {}\nCurrently live indexes:\n{}",
513                        pq.action,
514                        stmt,
515                        live_indexes
516                            .iter()
517                            .map(|s| format!("  {}", s))
518                            .collect::<Vec<_>>()
519                            .join("\n")
520                    );
521                }
522            }
523
524            // DROP INDEX removes from live set
525            for stmt in &stmts {
526                if stmt.starts_with("DROP INDEX") {
527                    live_indexes.retain(|s| {
528                        // Extract index name from DROP INDEX "name"
529                        let drop_name = stmt
530                            .strip_prefix("DROP INDEX \"")
531                            .and_then(|s| s.strip_suffix('"'));
532                        if let Some(name) = drop_name {
533                            !s.contains(&format!("\"{}\"", name))
534                        } else {
535                            true
536                        }
537                    });
538                }
539            }
540        }
541    }
542
543    // ── Add column + FK/Unique/Index – all orderings ─────────────────────
544
545    #[rstest]
546    #[case::fk_unique_index("fk_uq_ix", &[fk_constraint(), unique_constraint(), index_constraint()])]
547    #[case::fk_index_unique("fk_ix_uq", &[fk_constraint(), index_constraint(), unique_constraint()])]
548    #[case::unique_fk_index("uq_fk_ix", &[unique_constraint(), fk_constraint(), index_constraint()])]
549    #[case::unique_index_fk("uq_ix_fk", &[unique_constraint(), index_constraint(), fk_constraint()])]
550    #[case::index_fk_unique("ix_fk_uq", &[index_constraint(), fk_constraint(), unique_constraint()])]
551    #[case::index_unique_fk("ix_uq_fk", &[index_constraint(), unique_constraint(), fk_constraint()])]
552    fn test_add_column_with_fk_unique_index_all_orderings(
553        #[case] title: &str,
554        #[case] order: &[TableConstraint],
555    ) {
556        let plan = plan_add_column_with_constraints(order);
557        let schema = base_schema_no_constraints();
558        let result = build_plan_queries(&plan, &schema).unwrap();
559
560        // Core invariant: no conflicting duplicate indexes in SQLite
561        assert_no_duplicate_indexes_per_action(&result);
562        assert_no_orphan_duplicate_indexes(&result);
563
564        // Snapshot per backend
565        for (backend, label) in [
566            (DatabaseBackend::Postgres, "postgres"),
567            (DatabaseBackend::MySql, "mysql"),
568            (DatabaseBackend::Sqlite, "sqlite"),
569        ] {
570            let sql = collect_all_sql(&result, backend);
571            with_settings!({ snapshot_suffix => format!("add_col_{}_{}", title, label) }, {
572                assert_snapshot!(sql);
573            });
574        }
575    }
576
577    // ── Remove FK/Unique/Index then drop column – all orderings ──────────
578
579    #[rstest]
580    #[case::fk_unique_index("fk_uq_ix", &[fk_constraint(), unique_constraint(), index_constraint()])]
581    #[case::fk_index_unique("fk_ix_uq", &[fk_constraint(), index_constraint(), unique_constraint()])]
582    #[case::unique_fk_index("uq_fk_ix", &[unique_constraint(), fk_constraint(), index_constraint()])]
583    #[case::unique_index_fk("uq_ix_fk", &[unique_constraint(), index_constraint(), fk_constraint()])]
584    #[case::index_fk_unique("ix_fk_uq", &[index_constraint(), fk_constraint(), unique_constraint()])]
585    #[case::index_unique_fk("ix_uq_fk", &[index_constraint(), unique_constraint(), fk_constraint()])]
586    fn test_remove_fk_unique_index_then_drop_column_all_orderings(
587        #[case] title: &str,
588        #[case] order: &[TableConstraint],
589    ) {
590        let plan = plan_remove_constraints_then_drop(order);
591        let schema = base_schema_with_all_constraints();
592        let result = build_plan_queries(&plan, &schema).unwrap();
593
594        // Snapshot per backend
595        for (backend, label) in [
596            (DatabaseBackend::Postgres, "postgres"),
597            (DatabaseBackend::MySql, "mysql"),
598            (DatabaseBackend::Sqlite, "sqlite"),
599        ] {
600            let sql = collect_all_sql(&result, backend);
601            with_settings!({ snapshot_suffix => format!("rm_col_{}_{}", title, label) }, {
602                assert_snapshot!(sql);
603            });
604        }
605    }
606
607    // ── Pair-wise: FK + Index only (original bug scenario) ───────────────
608
609    #[rstest]
610    #[case::fk_then_index("fk_ix", &[fk_constraint(), index_constraint()])]
611    #[case::index_then_fk("ix_fk", &[index_constraint(), fk_constraint()])]
612    fn test_add_column_with_fk_and_index_pair(
613        #[case] title: &str,
614        #[case] order: &[TableConstraint],
615    ) {
616        let plan = plan_add_column_with_constraints(order);
617        let schema = base_schema_no_constraints();
618        let result = build_plan_queries(&plan, &schema).unwrap();
619
620        assert_no_duplicate_indexes_per_action(&result);
621        assert_no_orphan_duplicate_indexes(&result);
622
623        for (backend, label) in [
624            (DatabaseBackend::Postgres, "postgres"),
625            (DatabaseBackend::MySql, "mysql"),
626            (DatabaseBackend::Sqlite, "sqlite"),
627        ] {
628            let sql = collect_all_sql(&result, backend);
629            with_settings!({ snapshot_suffix => format!("add_col_pair_{}_{}", title, label) }, {
630                assert_snapshot!(sql);
631            });
632        }
633    }
634
635    // ── Pair-wise: FK + Unique only ──────────────────────────────────────
636
637    #[rstest]
638    #[case::fk_then_unique("fk_uq", &[fk_constraint(), unique_constraint()])]
639    #[case::unique_then_fk("uq_fk", &[unique_constraint(), fk_constraint()])]
640    fn test_add_column_with_fk_and_unique_pair(
641        #[case] title: &str,
642        #[case] order: &[TableConstraint],
643    ) {
644        let plan = plan_add_column_with_constraints(order);
645        let schema = base_schema_no_constraints();
646        let result = build_plan_queries(&plan, &schema).unwrap();
647
648        assert_no_duplicate_indexes_per_action(&result);
649        assert_no_orphan_duplicate_indexes(&result);
650
651        for (backend, label) in [
652            (DatabaseBackend::Postgres, "postgres"),
653            (DatabaseBackend::MySql, "mysql"),
654            (DatabaseBackend::Sqlite, "sqlite"),
655        ] {
656            let sql = collect_all_sql(&result, backend);
657            with_settings!({ snapshot_suffix => format!("add_col_pair_{}_{}", title, label) }, {
658                assert_snapshot!(sql);
659            });
660        }
661    }
662
663    // ── Duplicate FK in temp table CREATE TABLE ──────────────────────────
664
665    /// Regression test: when AddColumn adds a column with an inline FK, the
666    /// evolving schema already contains the FK constraint (from normalization).
667    /// Then AddConstraint FK pushes the same FK again into new_constraints,
668    /// producing a duplicate FOREIGN KEY clause in the SQLite temp table.
669    #[rstest]
670    #[case::postgres("postgres", DatabaseBackend::Postgres)]
671    #[case::mysql("mysql", DatabaseBackend::MySql)]
672    #[case::sqlite("sqlite", DatabaseBackend::Sqlite)]
673    fn test_add_column_with_fk_no_duplicate_fk_in_temp_table(
674        #[case] label: &str,
675        #[case] backend: DatabaseBackend,
676    ) {
677        let schema = vec![
678            TableDef {
679                name: "project".into(),
680                description: None,
681                columns: vec![col("id", ColumnType::Simple(SimpleColumnType::Integer))],
682                constraints: vec![],
683            },
684            TableDef {
685                name: "companion".into(),
686                description: None,
687                columns: vec![
688                    col("id", ColumnType::Simple(SimpleColumnType::Integer)),
689                    col("user_id", ColumnType::Simple(SimpleColumnType::BigInt)),
690                ],
691                constraints: vec![
692                    TableConstraint::ForeignKey {
693                        name: None,
694                        columns: vec!["user_id".into()],
695                        ref_table: "user".into(),
696                        ref_columns: vec!["id".into()],
697                        on_delete: Some(ReferenceAction::Cascade),
698                        on_update: None,
699                    },
700                    TableConstraint::Unique {
701                        name: Some("invite_code".into()),
702                        columns: vec!["invite_code".into()],
703                    },
704                    TableConstraint::Index {
705                        name: None,
706                        columns: vec!["user_id".into()],
707                    },
708                ],
709            },
710        ];
711
712        let plan = MigrationPlan {
713            id: String::new(),
714            comment: None,
715            created_at: None,
716            version: 1,
717            actions: vec![
718                MigrationAction::AddColumn {
719                    table: "companion".into(),
720                    column: Box::new(ColumnDef {
721                        name: "project_id".into(),
722                        r#type: ColumnType::Simple(SimpleColumnType::BigInt),
723                        nullable: false,
724                        default: None,
725                        comment: None,
726                        primary_key: None,
727                        unique: None,
728                        index: None,
729                        foreign_key: Some(
730                            vespertide_core::schema::foreign_key::ForeignKeySyntax::String(
731                                "project.id".into(),
732                            ),
733                        ),
734                    }),
735                    fill_with: None,
736                },
737                MigrationAction::AddConstraint {
738                    table: "companion".into(),
739                    constraint: TableConstraint::ForeignKey {
740                        name: None,
741                        columns: vec!["project_id".into()],
742                        ref_table: "project".into(),
743                        ref_columns: vec!["id".into()],
744                        on_delete: Some(ReferenceAction::Cascade),
745                        on_update: None,
746                    },
747                },
748                MigrationAction::AddConstraint {
749                    table: "companion".into(),
750                    constraint: TableConstraint::Index {
751                        name: None,
752                        columns: vec!["project_id".into()],
753                    },
754                },
755            ],
756        };
757
758        let result = build_plan_queries(&plan, &schema).unwrap();
759
760        assert_no_duplicate_indexes_per_action(&result);
761        assert_no_orphan_duplicate_indexes(&result);
762
763        let sql = collect_all_sql(&result, backend);
764        with_settings!({ snapshot_suffix => format!("dup_fk_{}", label) }, {
765            assert_snapshot!(sql);
766        });
767    }
768}