@@ -526,6 +526,42 @@ fn rewrite_plan_for_recreation(
526526 }
527527}
528528
529+ #[ derive( Debug , Clone , Copy , PartialEq , Eq ) ]
530+ enum RecreateHandling {
531+ NotNeeded ,
532+ Rewritten ,
533+ PlanEmptied ,
534+ }
535+
536+ fn handle_recreate_requirements < F > (
537+ plan : & mut MigrationPlan ,
538+ current_models : & [ TableDef ] ,
539+ prompt_fn : F ,
540+ ) -> Result < RecreateHandling >
541+ where
542+ F : Fn ( & [ RecreateTableRequired ] ) -> Result < bool > ,
543+ {
544+ let recreate_tables = find_non_nullable_fk_add_columns ( plan, current_models) ;
545+ if recreate_tables. is_empty ( ) {
546+ return Ok ( RecreateHandling :: NotNeeded ) ;
547+ }
548+
549+ if !prompt_fn ( & recreate_tables) ? {
550+ anyhow:: bail!(
551+ "Migration cancelled. To proceed without recreation, make the column nullable \
552+ or add it with a default value that references an existing row."
553+ ) ;
554+ }
555+
556+ rewrite_plan_for_recreation ( plan, & recreate_tables, current_models) ;
557+
558+ if plan. actions . is_empty ( ) {
559+ return Ok ( RecreateHandling :: PlanEmptied ) ;
560+ }
561+
562+ Ok ( RecreateHandling :: Rewritten )
563+ }
564+
529565pub async fn cmd_revision ( message : String , fill_with_args : Vec < String > ) -> Result < ( ) > {
530566 let config = load_config ( ) ?;
531567 let current_models = load_models ( & config) ?;
@@ -543,20 +579,10 @@ pub async fn cmd_revision(message: String, fill_with_args: Vec<String>) -> Resul
543579 return Ok ( ( ) ) ;
544580 }
545581
546- // Check for non-nullable FK columns being added to existing tables.
547- // These require table recreation because existing rows can't satisfy the FK constraint.
548- let recreate_tables = find_non_nullable_fk_add_columns ( & plan, & current_models) ;
549- if !recreate_tables. is_empty ( ) {
550- if !prompt_recreate_tables ( & recreate_tables) ? {
551- anyhow:: bail!(
552- "Migration cancelled. To proceed without recreation, make the column nullable \
553- or add it with a default value that references an existing row."
554- ) ;
555- }
556- rewrite_plan_for_recreation ( & mut plan, & recreate_tables, & current_models) ;
557-
558- // Re-check: if plan is now empty after recreation rewrite, nothing to do
559- if plan. actions . is_empty ( ) {
582+ // Check for non-nullable FK changes that require table recreation.
583+ match handle_recreate_requirements ( & mut plan, & current_models, prompt_recreate_tables) ? {
584+ RecreateHandling :: NotNeeded | RecreateHandling :: Rewritten => { }
585+ RecreateHandling :: PlanEmptied => {
560586 println ! (
561587 "{} {}" ,
562588 "No changes detected." . bright_yellow( ) ,
@@ -1098,6 +1124,181 @@ mod tests {
10981124 ) ;
10991125 }
11001126
1127+ #[ test]
1128+ fn rewrite_plan_keeps_non_table_actions ( ) {
1129+ use vespertide_core:: { ColumnDef , ColumnType , SimpleColumnType } ;
1130+
1131+ let mut plan = MigrationPlan {
1132+ id : String :: new ( ) ,
1133+ comment : None ,
1134+ created_at : None ,
1135+ version : 2 ,
1136+ actions : vec ! [
1137+ MigrationAction :: RawSql {
1138+ sql: "select 1" . into( ) ,
1139+ } ,
1140+ MigrationAction :: AddColumn {
1141+ table: "post" . into( ) ,
1142+ column: Box :: new( ColumnDef {
1143+ name: "user_id" . into( ) ,
1144+ r#type: ColumnType :: Simple ( SimpleColumnType :: Uuid ) ,
1145+ nullable: false ,
1146+ default : None ,
1147+ comment: None ,
1148+ primary_key: None ,
1149+ unique: None ,
1150+ index: None ,
1151+ foreign_key: None ,
1152+ } ) ,
1153+ fill_with: None ,
1154+ } ,
1155+ ] ,
1156+ } ;
1157+
1158+ let recreate = vec ! [ RecreateTableRequired {
1159+ table: "post" . into( ) ,
1160+ column: "user_id" . into( ) ,
1161+ reason: RecreateReason :: AddColumnWithFk ,
1162+ } ] ;
1163+
1164+ let models = vec ! [ TableDef {
1165+ name: "post" . into( ) ,
1166+ description: None ,
1167+ columns: vec![ ColumnDef {
1168+ name: "user_id" . into( ) ,
1169+ r#type: ColumnType :: Simple ( SimpleColumnType :: Uuid ) ,
1170+ nullable: false ,
1171+ default : None ,
1172+ comment: None ,
1173+ primary_key: None ,
1174+ unique: None ,
1175+ index: None ,
1176+ foreign_key: None ,
1177+ } ] ,
1178+ constraints: vec![ ] ,
1179+ } ] ;
1180+
1181+ rewrite_plan_for_recreation ( & mut plan, & recreate, & models) ;
1182+
1183+ assert ! ( matches!( & plan. actions[ 0 ] , MigrationAction :: RawSql { sql } if sql == "select 1" ) ) ;
1184+ assert ! (
1185+ matches!( & plan. actions[ 1 ] , MigrationAction :: DeleteTable { table } if table == "post" )
1186+ ) ;
1187+ assert ! (
1188+ matches!( & plan. actions[ 2 ] , MigrationAction :: CreateTable { table, .. } if table == "post" )
1189+ ) ;
1190+ }
1191+
1192+ #[ test]
1193+ fn handle_recreate_requirements_returns_not_needed ( ) {
1194+ let mut plan = MigrationPlan {
1195+ id : String :: new ( ) ,
1196+ comment : None ,
1197+ created_at : None ,
1198+ version : 1 ,
1199+ actions : vec ! [ MigrationAction :: RawSql {
1200+ sql: "select 1" . into( ) ,
1201+ } ] ,
1202+ } ;
1203+
1204+ let result = handle_recreate_requirements ( & mut plan, & [ ] , |_| Ok ( true ) ) . unwrap ( ) ;
1205+
1206+ assert_eq ! ( result, RecreateHandling :: NotNeeded ) ;
1207+ assert_eq ! ( plan. actions. len( ) , 1 ) ;
1208+ }
1209+
1210+ #[ test]
1211+ fn handle_recreate_requirements_bails_when_prompt_rejected ( ) {
1212+ use vespertide_core:: { ColumnDef , ColumnType , SimpleColumnType } ;
1213+
1214+ let mut plan = MigrationPlan {
1215+ id : String :: new ( ) ,
1216+ comment : None ,
1217+ created_at : None ,
1218+ version : 1 ,
1219+ actions : vec ! [
1220+ MigrationAction :: AddColumn {
1221+ table: "post" . into( ) ,
1222+ column: Box :: new( ColumnDef {
1223+ name: "user_id" . into( ) ,
1224+ r#type: ColumnType :: Simple ( SimpleColumnType :: Uuid ) ,
1225+ nullable: false ,
1226+ default : None ,
1227+ comment: None ,
1228+ primary_key: None ,
1229+ unique: None ,
1230+ index: None ,
1231+ foreign_key: None ,
1232+ } ) ,
1233+ fill_with: None ,
1234+ } ,
1235+ MigrationAction :: AddConstraint {
1236+ table: "post" . into( ) ,
1237+ constraint: TableConstraint :: ForeignKey {
1238+ name: None ,
1239+ columns: vec![ "user_id" . into( ) ] ,
1240+ ref_table: "user" . into( ) ,
1241+ ref_columns: vec![ "id" . into( ) ] ,
1242+ on_delete: None ,
1243+ on_update: None ,
1244+ } ,
1245+ } ,
1246+ ] ,
1247+ } ;
1248+
1249+ let err = handle_recreate_requirements ( & mut plan, & [ ] , |_| Ok ( false ) ) . unwrap_err ( ) ;
1250+
1251+ assert ! (
1252+ err. to_string( )
1253+ . contains( "Migration cancelled. To proceed without recreation" )
1254+ ) ;
1255+ }
1256+
1257+ #[ test]
1258+ fn handle_recreate_requirements_returns_plan_emptied_when_model_missing ( ) {
1259+ use vespertide_core:: { ColumnDef , ColumnType , SimpleColumnType } ;
1260+
1261+ let mut plan = MigrationPlan {
1262+ id : String :: new ( ) ,
1263+ comment : None ,
1264+ created_at : None ,
1265+ version : 1 ,
1266+ actions : vec ! [
1267+ MigrationAction :: AddColumn {
1268+ table: "post" . into( ) ,
1269+ column: Box :: new( ColumnDef {
1270+ name: "user_id" . into( ) ,
1271+ r#type: ColumnType :: Simple ( SimpleColumnType :: Uuid ) ,
1272+ nullable: false ,
1273+ default : None ,
1274+ comment: None ,
1275+ primary_key: None ,
1276+ unique: None ,
1277+ index: None ,
1278+ foreign_key: None ,
1279+ } ) ,
1280+ fill_with: None ,
1281+ } ,
1282+ MigrationAction :: AddConstraint {
1283+ table: "post" . into( ) ,
1284+ constraint: TableConstraint :: ForeignKey {
1285+ name: None ,
1286+ columns: vec![ "user_id" . into( ) ] ,
1287+ ref_table: "user" . into( ) ,
1288+ ref_columns: vec![ "id" . into( ) ] ,
1289+ on_delete: None ,
1290+ on_update: None ,
1291+ } ,
1292+ } ,
1293+ ] ,
1294+ } ;
1295+
1296+ let result = handle_recreate_requirements ( & mut plan, & [ ] , |_| Ok ( true ) ) . unwrap ( ) ;
1297+
1298+ assert_eq ! ( result, RecreateHandling :: PlanEmptied ) ;
1299+ assert ! ( plan. actions. is_empty( ) ) ;
1300+ }
1301+
11011302 #[ test]
11021303 fn test_parse_fill_with_args ( ) {
11031304 let args = vec ! [
0 commit comments