Skip to main content

vespertide_query/builder/
mod.rs

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