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