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