use super::{Pattern, PatternContext, PatternError};
use crate::live_migrate::plan::{Step, StepKind, StepParameters};
use crate::migrate::SchemaOperation;
pub struct BackfillThenTighten;
impl Pattern for BackfillThenTighten {
const ID: &'static str = "backfill_then_tighten";
const IDEMPOTENT_PREDICATE: bool = true;
fn emit(op: &SchemaOperation, ctx: &PatternContext) -> Result<Vec<Step>, PatternError> {
let (table, column, fk) = match op {
SchemaOperation::AddForeignKey { table, column, fk } => (table, column, fk),
_ => {
return Err(PatternError::WrongOperation {
pattern: Self::ID,
reason: "expected AddForeignKey".to_string(),
});
}
};
let constraint_name = format!("fk_{table}_{column}");
let backfill_predicate = format!(
"SET {col_q} = NULL WHERE id IN (SELECT id FROM {tbl_q} WHERE {col_q} IS NOT NULL AND {col_q} NOT IN (SELECT {ref_col_q} FROM {ref_tbl_q}) LIMIT $1)",
col_q = quote_ident(column),
tbl_q = quote_ident(table),
ref_col_q = quote_ident(&fk.ref_column),
ref_tbl_q = quote_ident(&fk.ref_table),
);
let gate_query = format!(
"SELECT count(*) FROM {tbl_q} WHERE {col_q} IS NOT NULL AND {col_q} NOT IN (SELECT {ref_col_q} FROM {ref_tbl_q})",
tbl_q = quote_ident(table),
col_q = quote_ident(column),
ref_col_q = quote_ident(&fk.ref_column),
ref_tbl_q = quote_ident(&fk.ref_table),
);
let finalize_sql = format!(
"ALTER TABLE {tbl_q} VALIDATE CONSTRAINT {name_q}",
tbl_q = quote_ident(table),
name_q = quote_ident(&constraint_name),
);
Ok(vec![
Step {
kind: StepKind::ExpandSchema,
ordinal: 0,
parameters: StepParameters::ExpandSchema {
sql_segments: Vec::new(),
},
},
Step {
kind: StepKind::BackfillChunked,
ordinal: 1,
parameters: StepParameters::BackfillChunked {
table: table.clone(),
predicate_template: backfill_predicate,
chunk_size: ctx.backfill_chunk_size,
},
},
Step {
kind: StepKind::ValidateBackfill,
ordinal: 2,
parameters: StepParameters::ValidateBackfill { gate_query },
},
Step {
kind: StepKind::FinalizeConstraints,
ordinal: 3,
parameters: StepParameters::FinalizeConstraints {
sql_segments: vec![finalize_sql],
},
},
])
}
}
fn quote_ident(name: &str) -> String {
let mut out = String::with_capacity(name.len() + 2);
out.push('"');
out.push_str(name);
out.push('"');
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::migrate::schema::{ForeignKeySchema, OnDeleteSchema};
fn ctx() -> PatternContext {
PatternContext::with_defaults()
}
fn op() -> SchemaOperation {
SchemaOperation::AddForeignKey {
table: "vehicle".to_string(),
column: "owner_id".to_string(),
fk: ForeignKeySchema {
deferrable: false,
initially_deferred: false,
on_delete: OnDeleteSchema::Restrict,
ref_column: "id".to_string(),
ref_table: "owner".to_string(),
},
}
}
#[test]
fn emits_canonical_four_step_sequence() {
let steps = BackfillThenTighten::emit(&op(), &ctx()).unwrap();
assert_eq!(steps.len(), 4);
let kinds: Vec<_> = steps.iter().map(|s| s.kind).collect();
assert_eq!(
kinds,
vec![
StepKind::ExpandSchema,
StepKind::BackfillChunked,
StepKind::ValidateBackfill,
StepKind::FinalizeConstraints,
],
);
}
#[test]
fn emitted_ordinals_are_sequential_and_kinds_are_consistent() {
let steps = BackfillThenTighten::emit(&op(), &ctx()).unwrap();
for (idx, step) in steps.iter().enumerate() {
assert_eq!(step.ordinal as usize, idx);
assert_eq!(step.kind, step.parameters.kind());
}
}
#[test]
fn backfill_template_emits_complete_update_tail_with_set_and_limit() {
let steps = BackfillThenTighten::emit(&op(), &ctx()).unwrap();
let StepParameters::BackfillChunked {
predicate_template, ..
} = &steps[1].parameters
else {
panic!("expected BackfillChunked");
};
assert!(
predicate_template.contains("SET \"owner_id\" = NULL"),
"template must null-out offending FK column: {predicate_template}",
);
assert!(predicate_template.contains("\"owner\""));
assert!(predicate_template.contains("\"id\""));
assert!(
predicate_template.contains("NOT IN") && predicate_template.contains("IS NOT NULL"),
"predicate must isolate violators idempotently: {predicate_template}",
);
assert!(
predicate_template.contains("LIMIT $1"),
"template must bound row count via LIMIT $1: {predicate_template}",
);
assert!(
predicate_template.contains("WHERE id IN (SELECT id FROM"),
"template must use canonical id-IN-subquery shape: {predicate_template}",
);
assert!(
!predicate_template.contains("$2"),
"template must not bind any placeholder beyond $1: {predicate_template}",
);
}
#[test]
fn backfill_template_concatenates_into_valid_update_statement() {
let steps = BackfillThenTighten::emit(&op(), &ctx()).unwrap();
let StepParameters::BackfillChunked {
table,
predicate_template,
..
} = &steps[1].parameters
else {
panic!("expected BackfillChunked");
};
let stmt = format!("UPDATE {table} {predicate_template}");
assert!(stmt.contains("UPDATE"));
assert!(stmt.contains("SET"));
assert!(stmt.contains("WHERE"));
assert!(stmt.contains("LIMIT $1"));
let set_pos = stmt.find("SET").unwrap();
let where_pos = stmt.find("WHERE").unwrap();
assert!(set_pos < where_pos, "SET must precede WHERE: {stmt}");
}
#[test]
fn finalize_emits_validate_constraint() {
let steps = BackfillThenTighten::emit(&op(), &ctx()).unwrap();
let StepParameters::FinalizeConstraints { sql_segments } = &steps[3].parameters else {
panic!("expected FinalizeConstraints");
};
assert_eq!(sql_segments.len(), 1);
assert!(sql_segments[0].contains("VALIDATE CONSTRAINT"));
assert!(sql_segments[0].contains("\"vehicle\""));
assert!(sql_segments[0].contains("\"fk_vehicle_owner_id\""));
}
#[test]
fn rejects_drop_foreign_key() {
let op = SchemaOperation::DropForeignKey {
table: "vehicle".to_string(),
column: "owner_id".to_string(),
fk: ForeignKeySchema {
deferrable: false,
initially_deferred: false,
on_delete: OnDeleteSchema::Restrict,
ref_column: "id".to_string(),
ref_table: "owner".to_string(),
},
};
let err = BackfillThenTighten::emit(&op, &ctx()).unwrap_err();
assert!(matches!(err, PatternError::WrongOperation { .. }));
}
#[test]
fn rejects_unrelated_alter_column() {
let op = SchemaOperation::AlterColumn {
table: "vehicle".to_string(),
column: "owner_id".to_string(),
change: crate::migrate::diff::ColumnChange::SetNullable(false),
};
let err = BackfillThenTighten::emit(&op, &ctx()).unwrap_err();
assert!(matches!(err, PatternError::WrongOperation { .. }));
}
}