Skip to main content

vespertide_query/builder/
mod.rs

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