use super::{Pattern, PatternContext, PatternError};
use crate::live_migrate::plan::{Step, StepKind, StepParameters};
use crate::migrate::SchemaOperation;
use crate::migrate::diff::ColumnChange;
pub struct CodecTransition;
impl Pattern for CodecTransition {
const ID: &'static str = "codec_transition";
const IDEMPOTENT_PREDICATE: bool = true;
fn emit(op: &SchemaOperation, ctx: &PatternContext) -> Result<Vec<Step>, PatternError> {
if let SchemaOperation::AlterColumn {
change: ColumnChange::ChangeType { using: Some(_), .. },
..
} = op
{
return Err(PatternError::CannotEmit {
pattern: Self::ID,
reason: "ColumnChange::ChangeType carries adopter-supplied `using` \
(#[field(type_change_using = \"...\")]); codec rotation \
emits `djogi_codec_recode(...)` in its backfill and has \
no slot for an adopter SQL fragment. The classifier \
routes this case to OfflineOnly — apply the migration \
via the offline path"
.to_string(),
});
}
let (table, column, from_codec, to_codec) = match op {
SchemaOperation::AlterColumn {
table,
column,
change: ColumnChange::ChangeType { from, to, .. },
} => (table, column, from, to),
_ => {
return Err(PatternError::WrongOperation {
pattern: Self::ID,
reason: "expected AlterColumn { change: ChangeType } carrying codec ids"
.to_string(),
});
}
};
let shadow = format!("{column}_new");
let expand_sql = format!(
"ALTER TABLE {tbl} ADD COLUMN {shadow_q} BYTEA NULL",
tbl = quote_ident(table),
shadow_q = quote_ident(&shadow),
);
let backfill_predicate = format!(
"SET {shadow_q} = djogi_codec_recode({col_q}, '{from}', '{to}') WHERE id IN (SELECT id FROM {tbl} WHERE {shadow_q} IS NULL LIMIT $1)",
shadow_q = quote_ident(&shadow),
col_q = quote_ident(column),
from = from_codec,
to = to_codec,
tbl = quote_ident(table),
);
let gate_query = format!(
"SELECT count(*) FROM {tbl} WHERE {shadow_q} IS NULL",
tbl = quote_ident(table),
shadow_q = quote_ident(&shadow),
);
let drop_legacy_sql = format!(
"ALTER TABLE {tbl} DROP COLUMN {col_q}",
tbl = quote_ident(table),
col_q = quote_ident(column),
);
let rename_sql = format!(
"ALTER TABLE {tbl} RENAME COLUMN {shadow_q} TO {col_q}",
tbl = quote_ident(table),
shadow_q = quote_ident(&shadow),
col_q = quote_ident(column),
);
Ok(vec![
Step {
kind: StepKind::ExpandSchema,
ordinal: 0,
parameters: StepParameters::ExpandSchema {
sql_segments: vec![expand_sql],
},
},
Step {
kind: StepKind::BeginCompatibilityWindow,
ordinal: 1,
parameters: StepParameters::BeginCompatibilityWindow {
hooks: vec![
format!("dual_read::codec::{table}::{column}::{from_codec}"),
format!("dual_write::codec::{table}::{column}::{from_codec}->{to_codec}",),
],
},
},
Step {
kind: StepKind::BackfillChunked,
ordinal: 2,
parameters: StepParameters::BackfillChunked {
table: table.clone(),
predicate_template: backfill_predicate,
chunk_size: ctx.backfill_chunk_size,
},
},
Step {
kind: StepKind::ValidateBackfill,
ordinal: 3,
parameters: StepParameters::ValidateBackfill { gate_query },
},
Step {
kind: StepKind::CutoverReads,
ordinal: 4,
parameters: StepParameters::CutoverReads {
description: format!(
"flip read path for {table}.{column} onto codec {to_codec}",
),
},
},
Step {
kind: StepKind::CutoverWrites,
ordinal: 5,
parameters: StepParameters::CutoverWrites {
description: format!(
"flip write path for {table}.{column} onto codec {to_codec}; drop dual-write",
),
},
},
Step {
kind: StepKind::CleanupLegacyState,
ordinal: 6,
parameters: StepParameters::CleanupLegacyState {
sql_segments: vec![drop_legacy_sql, rename_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: "secret".to_string(),
column: "ciphertext".to_string(),
change: ColumnChange::ChangeType {
from: "aes_gcm_v1".to_string(),
to: "aes_gcm_v2".to_string(),
using: None,
},
}
}
#[test]
fn emits_seven_step_codec_rotation_sequence() {
let steps = CodecTransition::emit(&op(), &ctx()).unwrap();
assert_eq!(steps.len(), 7);
let kinds: Vec<_> = steps.iter().map(|s| s.kind).collect();
assert_eq!(
kinds,
vec![
StepKind::ExpandSchema,
StepKind::BeginCompatibilityWindow,
StepKind::BackfillChunked,
StepKind::ValidateBackfill,
StepKind::CutoverReads,
StepKind::CutoverWrites,
StepKind::CleanupLegacyState,
],
);
}
#[test]
fn emitted_ordinals_are_sequential_and_kinds_are_consistent() {
let steps = CodecTransition::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 compatibility_window_hooks_carry_old_and_new_codec_ids() {
let steps = CodecTransition::emit(&op(), &ctx()).unwrap();
let StepParameters::BeginCompatibilityWindow { hooks } = &steps[1].parameters else {
panic!("expected BeginCompatibilityWindow at ordinal 1");
};
assert_eq!(hooks.len(), 2);
assert!(hooks.iter().any(|h| h.contains("aes_gcm_v1")));
assert!(hooks.iter().any(|h| h.contains("aes_gcm_v2")));
}
#[test]
fn backfill_template_emits_complete_update_tail_with_set_and_limit() {
let steps = CodecTransition::emit(&op(), &ctx()).unwrap();
let StepParameters::BackfillChunked {
predicate_template, ..
} = &steps[2].parameters
else {
panic!("expected BackfillChunked");
};
assert!(predicate_template.contains("SET"));
assert!(predicate_template.contains("\"ciphertext_new\""));
assert!(predicate_template.contains("djogi_codec_recode"));
assert!(predicate_template.contains("'aes_gcm_v1'"));
assert!(predicate_template.contains("'aes_gcm_v2'"));
assert!(predicate_template.contains("IS NULL"));
assert!(predicate_template.contains("LIMIT $1"));
assert!(predicate_template.contains("WHERE id IN (SELECT id FROM"));
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 = CodecTransition::emit(&op(), &ctx()).unwrap();
let StepParameters::BackfillChunked {
table,
predicate_template,
..
} = &steps[2].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 shadow_column_uses_canonical_new_suffix() {
let steps = CodecTransition::emit(&op(), &ctx()).unwrap();
let StepParameters::ExpandSchema { sql_segments } = &steps[0].parameters else {
panic!("expected ExpandSchema");
};
assert_eq!(sql_segments.len(), 1);
assert!(
sql_segments[0].contains("\"ciphertext_new\""),
"shadow column must use the canonical _new suffix: {}",
sql_segments[0],
);
assert!(
!sql_segments[0].contains("_recoded"),
"shadow column must NOT use the legacy _recoded suffix: {}",
sql_segments[0],
);
}
#[test]
fn cleanup_drops_legacy_then_renames_shadow() {
let steps = CodecTransition::emit(&op(), &ctx()).unwrap();
let StepParameters::CleanupLegacyState { sql_segments } = &steps[6].parameters else {
panic!("expected CleanupLegacyState");
};
assert_eq!(sql_segments.len(), 2);
assert!(sql_segments[0].contains("DROP COLUMN \"ciphertext\""));
assert!(sql_segments[1].contains("RENAME COLUMN \"ciphertext_new\" TO \"ciphertext\""));
}
#[test]
fn rejects_set_nullable() {
let op = SchemaOperation::AlterColumn {
table: "secret".to_string(),
column: "ciphertext".to_string(),
change: ColumnChange::SetNullable(false),
};
let err = CodecTransition::emit(&op, &ctx()).unwrap_err();
assert!(matches!(err, PatternError::WrongOperation { .. }));
}
#[test]
fn rejects_change_type_with_adopter_using() {
let op = SchemaOperation::AlterColumn {
table: "secret".to_string(),
column: "ciphertext".to_string(),
change: ColumnChange::ChangeType {
from: "aes_gcm_v1".to_string(),
to: "aes_gcm_v2".to_string(),
using: Some("djogi_codec_recode(ciphertext, 'a', 'b')".to_string()),
},
};
let err = CodecTransition::emit(&op, &ctx()).unwrap_err();
match err {
PatternError::CannotEmit { reason, .. } => {
assert!(
reason.contains("type_change_using") || reason.contains("USING"),
"refusal reason must name the corrective attribute: {reason}",
);
assert!(
reason.contains("offline") || reason.contains("OfflineOnly"),
"refusal reason must point at the offline path: {reason}",
);
}
other => panic!("expected CannotEmit, got: {other:?}"),
}
}
}