@@ -13,6 +13,25 @@ pub struct PlanQueries {
1313 pub sqlite : Vec < BuiltQuery > ,
1414}
1515
16+ /// Extract the target table name from any migration action.
17+ /// Returns `None` for `RawSql` (no table) and `RenameTable` (ambiguous).
18+ fn 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, .. } => Some ( table) ,
31+ MigrationAction :: RenameTable { .. } | MigrationAction :: RawSql { .. } => None ,
32+ }
33+ }
34+
1635pub fn build_plan_queries (
1736 plan : & MigrationPlan ,
1837 current_schema : & [ TableDef ] ,
@@ -27,8 +46,13 @@ pub fn build_plan_queries(
2746 // but haven't been physically created as DB indexes yet.
2847 // Without this, a temp table rebuild would recreate these indexes prematurely,
2948 // causing "index already exists" errors when their AddConstraint actions run later.
49+ //
50+ // This applies to ANY action that may trigger a SQLite temp table rebuild
51+ // (AddColumn with NOT NULL, ModifyColumn*, DeleteColumn, AddConstraint FK/PK/Check,
52+ // RemoveConstraint), not just AddConstraint.
53+ let action_table = action_target_table ( action) ;
3054 let pending_constraints: Vec < vespertide_core:: TableConstraint > =
31- if let MigrationAction :: AddConstraint { table, .. } = action {
55+ if let Some ( table) = action_table {
3256 plan. actions [ i + 1 ..]
3357 . iter ( )
3458 . filter_map ( |a| {
@@ -765,4 +789,102 @@ mod tests {
765789 assert_snapshot!( sql) ;
766790 } ) ;
767791 }
792+
793+ // ── Two NOT NULL AddColumns with inline index + AddConstraint ────────
794+
795+ /// Regression test: when two NOT NULL columns with inline `index: true`
796+ /// are added sequentially, the second AddColumn triggers a SQLite temp
797+ /// table rebuild. At that point the evolving schema already contains the
798+ /// first column's index (from normalization). Without pending constraint
799+ /// awareness, the rebuild recreates that index, and the later
800+ /// AddConstraint for the same index fails with "index already exists".
801+ #[ rstest]
802+ #[ case:: postgres( "postgres" , DatabaseBackend :: Postgres ) ]
803+ #[ case:: mysql( "mysql" , DatabaseBackend :: MySql ) ]
804+ #[ case:: sqlite( "sqlite" , DatabaseBackend :: Sqlite ) ]
805+ fn test_two_not_null_add_columns_with_inline_index_no_duplicate (
806+ #[ case] label : & str ,
807+ #[ case] backend : DatabaseBackend ,
808+ ) {
809+ use vespertide_core:: DefaultValue ;
810+ use vespertide_core:: schema:: str_or_bool:: StrOrBoolOrArray ;
811+
812+ let schema = vec ! [ TableDef {
813+ name: "article" . into( ) ,
814+ description: None ,
815+ columns: vec![
816+ col( "id" , ColumnType :: Simple ( SimpleColumnType :: Integer ) ) ,
817+ col( "title" , ColumnType :: Simple ( SimpleColumnType :: Text ) ) ,
818+ ] ,
819+ constraints: vec![ ] ,
820+ } ] ;
821+
822+ let plan = MigrationPlan {
823+ id : String :: new ( ) ,
824+ comment : None ,
825+ created_at : None ,
826+ version : 1 ,
827+ actions : vec ! [
828+ // 1. Add NOT NULL column with inline index
829+ MigrationAction :: AddColumn {
830+ table: "article" . into( ) ,
831+ column: Box :: new( ColumnDef {
832+ name: "category_pinned" . into( ) ,
833+ r#type: ColumnType :: Simple ( SimpleColumnType :: Boolean ) ,
834+ nullable: false ,
835+ default : Some ( DefaultValue :: Bool ( false ) ) ,
836+ comment: None ,
837+ primary_key: None ,
838+ unique: None ,
839+ index: Some ( StrOrBoolOrArray :: Bool ( true ) ) ,
840+ foreign_key: None ,
841+ } ) ,
842+ fill_with: None ,
843+ } ,
844+ // 2. Add another NOT NULL column with inline index
845+ MigrationAction :: AddColumn {
846+ table: "article" . into( ) ,
847+ column: Box :: new( ColumnDef {
848+ name: "main_pinned" . into( ) ,
849+ r#type: ColumnType :: Simple ( SimpleColumnType :: Boolean ) ,
850+ nullable: false ,
851+ default : Some ( DefaultValue :: Bool ( false ) ) ,
852+ comment: None ,
853+ primary_key: None ,
854+ unique: None ,
855+ index: Some ( StrOrBoolOrArray :: Bool ( true ) ) ,
856+ foreign_key: None ,
857+ } ) ,
858+ fill_with: None ,
859+ } ,
860+ // 3. AddConstraint for main_pinned index
861+ MigrationAction :: AddConstraint {
862+ table: "article" . into( ) ,
863+ constraint: TableConstraint :: Index {
864+ name: None ,
865+ columns: vec![ "main_pinned" . into( ) ] ,
866+ } ,
867+ } ,
868+ // 4. AddConstraint for category_pinned index
869+ MigrationAction :: AddConstraint {
870+ table: "article" . into( ) ,
871+ constraint: TableConstraint :: Index {
872+ name: None ,
873+ columns: vec![ "category_pinned" . into( ) ] ,
874+ } ,
875+ } ,
876+ ] ,
877+ } ;
878+
879+ let result = build_plan_queries ( & plan, & schema) . unwrap ( ) ;
880+
881+ // Core invariant: no duplicate indexes across actions
882+ assert_no_duplicate_indexes_per_action ( & result) ;
883+ assert_no_orphan_duplicate_indexes ( & result) ;
884+
885+ let sql = collect_all_sql ( & result, backend) ;
886+ with_settings ! ( { snapshot_suffix => format!( "two_not_null_inline_index_{}" , label) } , {
887+ assert_snapshot!( sql) ;
888+ } ) ;
889+ }
768890}
0 commit comments