use super::{Pattern, PatternContext, PatternError};
use crate::live_migrate::plan::{Step, StepKind, StepParameters};
use crate::migrate::SchemaOperation;
use crate::migrate::diff::ColumnChange;
pub struct NullableNotNull;
impl Pattern for NullableNotNull {
const ID: &'static str = "nullable_not_null";
const IDEMPOTENT_PREDICATE: bool = false;
fn emit(op: &SchemaOperation, _ctx: &PatternContext) -> Result<Vec<Step>, PatternError> {
let (table, column) = match op {
SchemaOperation::AlterColumn {
table,
column,
change: ColumnChange::SetNullable(false),
} => (table, column),
_ => {
return Err(PatternError::WrongOperation {
pattern: Self::ID,
reason: "expected AlterColumn { change: SetNullable(false) }".to_string(),
});
}
};
let gate_query = format!(
"SELECT count(*) FROM {tbl} WHERE {col} IS NULL",
tbl = quote_ident(table),
col = quote_ident(column),
);
let finalize_sql = format!(
"ALTER TABLE {tbl} ALTER COLUMN {col} SET NOT NULL",
tbl = quote_ident(table),
col = quote_ident(column),
);
Ok(vec![
Step {
kind: StepKind::ExpandSchema,
ordinal: 0,
parameters: StepParameters::ExpandSchema {
sql_segments: Vec::new(),
},
},
Step {
kind: StepKind::ValidateBackfill,
ordinal: 1,
parameters: StepParameters::ValidateBackfill { gate_query },
},
Step {
kind: StepKind::FinalizeConstraints,
ordinal: 2,
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::*;
fn ctx() -> PatternContext {
PatternContext::with_defaults()
}
fn op() -> SchemaOperation {
SchemaOperation::AlterColumn {
table: "vehicle".to_string(),
column: "owner_id".to_string(),
change: ColumnChange::SetNullable(false),
}
}
#[test]
fn emits_canonical_three_step_sequence() {
let steps = NullableNotNull::emit(&op(), &ctx()).unwrap();
assert_eq!(steps.len(), 3);
assert_eq!(steps[0].kind, StepKind::ExpandSchema);
assert_eq!(steps[1].kind, StepKind::ValidateBackfill);
assert_eq!(steps[2].kind, StepKind::FinalizeConstraints);
}
#[test]
fn emits_no_backfill_chunked_step() {
let steps = NullableNotNull::emit(&op(), &ctx()).unwrap();
assert!(
steps
.iter()
.all(|s| !matches!(s.parameters, StepParameters::BackfillChunked { .. })),
"nullable_not_null must NOT emit BackfillChunked",
);
}
#[test]
fn emitted_step_ordinals_are_sequential() {
let steps = NullableNotNull::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 validate_gate_uses_is_null_count() {
let steps = NullableNotNull::emit(&op(), &ctx()).unwrap();
let StepParameters::ValidateBackfill { gate_query } = &steps[1].parameters else {
panic!("expected ValidateBackfill at ordinal 1");
};
assert!(gate_query.contains("count(*)"));
assert!(gate_query.contains("IS NULL"));
assert!(gate_query.contains("\"owner_id\""));
assert!(gate_query.contains("\"vehicle\""));
}
#[test]
fn finalize_emits_set_not_null() {
let steps = NullableNotNull::emit(&op(), &ctx()).unwrap();
let StepParameters::FinalizeConstraints { sql_segments } = &steps[2].parameters else {
panic!("expected FinalizeConstraints at ordinal 2");
};
assert_eq!(sql_segments.len(), 1);
assert!(sql_segments[0].contains("SET NOT NULL"));
assert!(sql_segments[0].contains("\"vehicle\""));
assert!(sql_segments[0].contains("\"owner_id\""));
}
#[test]
fn rejects_set_nullable_true() {
let op = SchemaOperation::AlterColumn {
table: "vehicle".to_string(),
column: "owner_id".to_string(),
change: ColumnChange::SetNullable(true),
};
let err = NullableNotNull::emit(&op, &ctx()).unwrap_err();
assert!(matches!(err, PatternError::WrongOperation { .. }));
}
#[test]
fn rejects_unrelated_operation_kind() {
let op = SchemaOperation::DropColumn {
table: "vehicle".to_string(),
column: "owner_id".to_string(),
};
let err = NullableNotNull::emit(&op, &ctx()).unwrap_err();
assert!(matches!(err, PatternError::WrongOperation { .. }));
}
}