use std::fmt::Write as _;
use super::diff::{PkFlipChild, PkFlipDirection, PkFlipFamily, PkTypeFlipGroup};
use super::projection::BucketKey;
use super::schema::{OnDeleteSchema, PartitionSchema};
use super::segment::{MigrationPlan, Segment, SegmentKind};
use super::sql::{LossyRollbackKind, LossyRollbackWarning, OperationSql};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PkFlipError {
MalformedSelfFkMetadata {
parent_table: String,
fk_columns: usize,
fk_constraint_names: usize,
fk_deferrable: usize,
fk_initially_deferred: usize,
},
}
impl std::fmt::Display for PkFlipError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PkFlipError::MalformedSelfFkMetadata {
parent_table,
fk_columns,
fk_constraint_names,
fk_deferrable,
fk_initially_deferred,
} => write!(
f,
"PK-flip group for `{parent_table}` has mismatched self-FK metadata lengths: \
fk_columns={fk_columns}, fk_constraint_names={fk_constraint_names}, \
fk_deferrable={fk_deferrable}, \
fk_initially_deferred={fk_initially_deferred}"
),
}
}
}
impl std::error::Error for PkFlipError {}
pub fn lower_pk_flip_group(
group: &PkTypeFlipGroup,
bucket: BucketKey,
) -> Result<MigrationPlan, PkFlipError> {
let segments = build_segments(group)?;
Ok(MigrationPlan {
bucket,
classification: super::diff::Classification::PkTypeFlip {
co_destructive: group.co_destructive,
co_lossy: group.co_lossy,
},
segments,
})
}
fn validate_group(group: &PkTypeFlipGroup) -> Result<(), PkFlipError> {
if let Some(self_fk) = &group.self_fk {
let expected = self_fk.fk_columns.len();
let actuals = [
self_fk.fk_constraint_names.len(),
self_fk.fk_deferrable.len(),
self_fk.fk_initially_deferred.len(),
];
if actuals.iter().any(|actual| *actual != expected) {
return Err(PkFlipError::MalformedSelfFkMetadata {
parent_table: group.parent_table.clone(),
fk_columns: expected,
fk_constraint_names: self_fk.fk_constraint_names.len(),
fk_deferrable: self_fk.fk_deferrable.len(),
fk_initially_deferred: self_fk.fk_initially_deferred.len(),
});
}
}
Ok(())
}
pub(crate) fn build_segments(group: &PkTypeFlipGroup) -> Result<Vec<Segment>, PkFlipError> {
validate_group(group)?;
if let Some(part) = &group.partitioned_parent {
return Ok(build_segments_partitioned(group, part));
}
let mut segments: Vec<Segment> = Vec::new();
segments.push(Segment {
kind: SegmentKind::Transactional,
statements: vec![emit_preparation(group)],
});
segments.push(Segment {
kind: SegmentKind::NonTransactional,
statements: emit_backfill_statements(group),
});
let verifications = emit_verification_statements(group);
if !verifications.is_empty() {
segments.push(Segment {
kind: SegmentKind::Transactional,
statements: verifications,
});
}
segments.push(Segment {
kind: SegmentKind::NonTransactional,
statements: emit_concurrent_index_statements(group),
});
let fk_stmts = emit_child_fk_statements(group);
if !fk_stmts.is_empty() {
segments.push(Segment {
kind: SegmentKind::Transactional,
statements: fk_stmts,
});
}
segments.push(Segment {
kind: SegmentKind::Transactional,
statements: vec![emit_not_null_proof(group)],
});
segments.push(Segment {
kind: SegmentKind::Transactional,
statements: vec![emit_cutover(group)],
});
Ok(segments)
}
pub(crate) fn build_segments_multi(
groups: &[PkTypeFlipGroup],
) -> Result<Vec<Segment>, super::diff::DiffError> {
if groups.is_empty() {
return Ok(Vec::new());
}
for g in groups {
validate_group(g)?;
}
if groups.len() == 1 {
return Ok(build_segments(&groups[0])?);
}
if let Some(err) = super::diff::partitioned_multi_parent_cluster_error(groups) {
return Err(err);
}
let mut segments: Vec<Segment> = Vec::new();
let mut prep_up = String::new();
let mut prep_down = String::new();
for g in groups {
let prep = emit_preparation(g);
if !prep_up.is_empty() && !prep.up.is_empty() {
prep_up.push('\n');
}
prep_up.push_str(&prep.up);
if !prep_down.is_empty() && !prep.down.is_empty() {
prep_down.push('\n');
}
prep_down.push_str(&prep.down);
}
let cluster_label = groups
.iter()
.map(|g| g.parent_table.as_str())
.collect::<Vec<_>>()
.join(",");
segments.push(Segment {
kind: SegmentKind::Transactional,
statements: vec![OperationSql {
label: format!("PkFlipPrepMulti [{cluster_label}]"),
up: prep_up,
down: prep_down,
lossy: None,
}],
});
let mut backfill_stmts: Vec<OperationSql> = Vec::new();
for g in groups {
backfill_stmts.extend(emit_backfill_statements(g));
}
segments.push(Segment {
kind: SegmentKind::NonTransactional,
statements: backfill_stmts,
});
let mut verify_stmts: Vec<OperationSql> = Vec::new();
for g in groups {
verify_stmts.extend(emit_verification_statements(g));
}
if !verify_stmts.is_empty() {
segments.push(Segment {
kind: SegmentKind::Transactional,
statements: verify_stmts,
});
}
let mut index_stmts: Vec<OperationSql> = Vec::new();
for g in groups {
index_stmts.extend(emit_concurrent_index_statements(g));
}
segments.push(Segment {
kind: SegmentKind::NonTransactional,
statements: index_stmts,
});
let mut fk_stmts: Vec<OperationSql> = Vec::new();
for g in groups {
fk_stmts.extend(emit_child_fk_statements(g));
}
if !fk_stmts.is_empty() {
segments.push(Segment {
kind: SegmentKind::Transactional,
statements: fk_stmts,
});
}
let mut nn_up = String::new();
let mut nn_down = String::new();
for g in groups {
let nn = emit_not_null_proof(g);
if !nn_up.is_empty() && !nn.up.is_empty() {
nn_up.push('\n');
}
nn_up.push_str(&nn.up);
if !nn_down.is_empty() && !nn.down.is_empty() {
nn_down.push('\n');
}
nn_down.push_str(&nn.down);
}
segments.push(Segment {
kind: SegmentKind::Transactional,
statements: vec![OperationSql {
label: format!("PkFlipNotNullProofMulti [{cluster_label}]"),
up: nn_up,
down: nn_down,
lossy: None,
}],
});
let mut cut_up = String::new();
if groups.iter().any(|g| !g.cycles.is_empty()) {
cut_up.push_str("SET CONSTRAINTS ALL DEFERRED;\n");
}
for g in groups {
cutover_phase_drop_old_fks(g, &mut cut_up);
}
for g in groups {
cutover_phase_promote_parent(g, &mut cut_up);
}
for g in groups {
cutover_phase_finalise_children(g, &mut cut_up);
}
for g in groups {
cutover_phase_finalise_join_tables(g, &mut cut_up);
}
let cut_down = format!(
"-- POINT OF NO RETURN — segment 5 (cutover) for cluster [{cluster_label}] cannot be\n\
-- reversed by `down` SQL alone. Rollback requires an inverse\n\
-- migration: add the previous-direction columns back, install\n\
-- reverse autofill triggers, re-run heeranjid_bulk_backfill on\n\
-- every member, and run a second cutover. Plan that contingency\n\
-- BEFORE running the forward cutover.",
);
segments.push(Segment {
kind: SegmentKind::Transactional,
statements: vec![OperationSql {
label: format!("PkFlipCutoverMulti [{cluster_label}]"),
up: cut_up,
down: cut_down,
lossy: Some(LossyRollbackWarning {
kind: LossyRollbackKind::PkTypeFlipPostCutover,
detail: format!(
"POINT OF NO RETURN: cutover for cluster [{cluster_label}] removes \
prior PK columns and triggers across every member; rollback \
requires an inverse migration",
),
}),
}],
});
Ok(segments)
}
fn build_segments_partitioned(
group: &PkTypeFlipGroup,
part: &super::diff::PkFlipPartitionedMeta,
) -> Vec<Segment> {
let mut prep_op = emit_partitioned_preparation(group, part);
let cascade_prep = emit_preparation_children_only(group);
if !cascade_prep.up.is_empty() {
prep_op.up.push_str(&cascade_prep.up);
prep_op.down = format!("{}\n{}", cascade_prep.down, prep_op.down);
}
let mut backfill_stmts: Vec<OperationSql> = Vec::new();
backfill_stmts.push(emit_partitioned_backfill_only(group, part));
let cascade_bf = emit_backfill_statements_cascade_only(group);
backfill_stmts.extend(cascade_bf);
let mut verify_stmts: Vec<OperationSql> = Vec::new();
verify_stmts.push(emit_partitioned_verify(group));
let cascade_verify = emit_verification_statements_cascade_only(group);
verify_stmts.extend(cascade_verify);
let mut index_stmts: Vec<OperationSql> = Vec::new();
index_stmts.push(emit_partitioned_indexes(group, part));
index_stmts.extend(emit_partitioned_self_fk_indexes(group));
let cascade_idx = emit_concurrent_index_statements_cascade_only(group);
index_stmts.extend(cascade_idx);
let fk_stmts = emit_child_fk_statements(group);
let nn_op = emit_not_null_proof(group);
let cutover_op = emit_partitioned_cutover_with_cascade(group, part);
let mut segments: Vec<Segment> = vec![
Segment {
kind: SegmentKind::Transactional,
statements: vec![prep_op],
},
Segment {
kind: SegmentKind::NonTransactional,
statements: backfill_stmts,
},
Segment {
kind: SegmentKind::Transactional,
statements: verify_stmts,
},
Segment {
kind: SegmentKind::NonTransactional,
statements: index_stmts,
},
];
if !fk_stmts.is_empty() {
segments.push(Segment {
kind: SegmentKind::Transactional,
statements: fk_stmts,
});
}
segments.push(Segment {
kind: SegmentKind::Transactional,
statements: vec![nn_op],
});
segments.push(Segment {
kind: SegmentKind::Transactional,
statements: vec![cutover_op],
});
segments
}
fn emit_preparation_children_only(group: &PkTypeFlipGroup) -> OperationSql {
emit_preparation_with_mode(group, EmitMode::CascadeOnly)
}
fn emit_backfill_statements_cascade_only(group: &PkTypeFlipGroup) -> Vec<OperationSql> {
emit_backfill_statements_with_mode(group, EmitMode::CascadeOnly)
}
fn emit_verification_statements_cascade_only(group: &PkTypeFlipGroup) -> Vec<OperationSql> {
emit_verification_statements_with_mode(group, EmitMode::CascadeOnly)
}
fn emit_concurrent_index_statements_cascade_only(group: &PkTypeFlipGroup) -> Vec<OperationSql> {
emit_concurrent_index_statements_with_mode(group, EmitMode::CascadeOnly)
}
fn emit_partitioned_cutover_with_cascade(
group: &PkTypeFlipGroup,
_part: &super::diff::PkFlipPartitionedMeta,
) -> OperationSql {
let parent = group.parent_table.as_str();
let p_family = parent_family(group);
let next_fn = next_fn_name(p_family, group.direction);
let part_col = match &group.partitioned_parent {
Some(meta) => match &meta.partition {
PartitionSchema::Range { column } => column.clone(),
PartitionSchema::Hash { column, .. } => column.clone(),
},
None => "partition_key".to_string(),
};
let mut up = String::new();
if !group.cycles.is_empty() {
up.push_str("SET CONSTRAINTS ALL DEFERRED;\n");
}
cutover_phase_drop_old_fks(group, &mut up);
let _ = writeln!(
up,
"ALTER TABLE {parent} DROP CONSTRAINT {parent}_pkey;",
parent = parent,
);
let _ = writeln!(
up,
"ALTER TABLE {parent} ADD PRIMARY KEY ({pkey}, id{suffix});",
parent = parent,
pkey = part_col,
suffix = SHADOW_SUFFIX,
);
let _ = writeln!(
up,
"ALTER TABLE {parent} ALTER COLUMN id{suffix} SET DEFAULT {next}();",
parent = parent,
suffix = SHADOW_SUFFIX,
next = next_fn,
);
let _ = writeln!(
up,
"ALTER TABLE {parent} ALTER COLUMN id DROP DEFAULT;",
parent = parent,
);
let _ = writeln!(up, "ALTER TABLE {parent} DROP COLUMN id;", parent = parent);
let _ = writeln!(
up,
"DROP TRIGGER zzz_{parent}_autofill_desc ON {parent};",
parent = parent,
);
let _ = writeln!(
up,
"DROP FUNCTION zzz_{parent}_autofill_desc() CASCADE;",
parent = parent,
);
let _ = writeln!(
up,
"ALTER TABLE {parent} RENAME COLUMN id{suffix} TO id;",
parent = parent,
suffix = SHADOW_SUFFIX,
);
if let Some(self_fk) = &group.self_fk {
for col in &self_fk.fk_columns {
let _ = writeln!(
up,
"ALTER TABLE {parent} DROP COLUMN {col};",
parent = parent,
col = col,
);
}
for col in &self_fk.fk_columns {
let constraint = format!("{parent}_{col}{suffix}_fkey", suffix = SHADOW_SUFFIX);
cutover_drop_constraint(&mut up, parent, &constraint);
}
for col in &self_fk.fk_columns {
let _ = writeln!(
up,
"ALTER TABLE {parent} RENAME COLUMN {col}{suffix} TO {col};",
parent = parent,
col = col,
suffix = SHADOW_SUFFIX,
);
}
for (i, (col, cons)) in self_fk
.fk_columns
.iter()
.zip(self_fk.fk_constraint_names.iter())
.enumerate()
{
let deferrable_clause = render_deferrable_clause(
self_fk.fk_deferrable.get(i).copied().unwrap_or(false),
self_fk
.fk_initially_deferred
.get(i)
.copied()
.unwrap_or(false),
);
cutover_add_fk_constraint(&mut up, parent, cons, col, parent, "id", deferrable_clause);
}
}
cutover_phase_finalise_children(group, &mut up);
cutover_phase_finalise_join_tables(group, &mut up);
OperationSql {
label: format!("PkFlipPartitionedCutover {parent}"),
up,
down: format!(
"-- POINT OF NO RETURN — partitioned cutover for {parent} cannot be\n\
-- reversed by `down` SQL alone. Inverse migration required.",
),
lossy: Some(LossyRollbackWarning {
kind: LossyRollbackKind::PkTypeFlipPostCutover,
detail: format!(
"POINT OF NO RETURN: partitioned cutover for `{parent}` removes the prior \
PK column and trigger; rollback requires an inverse migration. \
Partitioned-table cutover is seconds-to-minutes class — benchmark first.",
),
}),
}
}
const PARENT_PK_COLUMN: &str = "id";
const SHADOW_SUFFIX: &str = "_desc";
#[derive(Clone, Copy, PartialEq, Eq)]
enum EmitMode {
Standard,
CascadeOnly,
}
impl EmitMode {
fn includes_parent(self) -> bool {
matches!(self, EmitMode::Standard)
}
}
fn pg_id_type(family: PkFlipFamily) -> &'static str {
match family {
PkFlipFamily::Heer => "bigint",
PkFlipFamily::Ranj => "uuid",
}
}
fn parent_family(group: &PkTypeFlipGroup) -> PkFlipFamily {
match group.parent_from {
super::schema::PkKindSchema::HeerId | super::schema::PkKindSchema::HeerIdRecencyBiased => {
PkFlipFamily::Heer
}
super::schema::PkKindSchema::RanjId | super::schema::PkKindSchema::RanjIdRecencyBiased => {
PkFlipFamily::Ranj
}
_ => PkFlipFamily::Heer,
}
}
fn flip_fn_name(family: PkFlipFamily, direction: PkFlipDirection) -> &'static str {
match (family, direction) {
(PkFlipFamily::Heer, PkFlipDirection::AscToDesc) => "heerid_to_desc",
(PkFlipFamily::Heer, PkFlipDirection::DescToAsc) => "heerid_to_asc",
(PkFlipFamily::Ranj, PkFlipDirection::AscToDesc) => "ranjid_to_desc",
(PkFlipFamily::Ranj, PkFlipDirection::DescToAsc) => "ranjid_to_asc",
}
}
fn next_fn_name(family: PkFlipFamily, direction: PkFlipDirection) -> &'static str {
match (family, direction) {
(PkFlipFamily::Heer, PkFlipDirection::AscToDesc) => "heerid_next_desc",
(PkFlipFamily::Heer, PkFlipDirection::DescToAsc) => "heerid_next",
(PkFlipFamily::Ranj, PkFlipDirection::AscToDesc) => "ranjid_next_desc",
(PkFlipFamily::Ranj, PkFlipDirection::DescToAsc) => "ranjid_next",
}
}
fn backfill_kind_literal(family: PkFlipFamily) -> &'static str {
match family {
PkFlipFamily::Heer => "'heer'",
PkFlipFamily::Ranj => "'ranj'",
}
}
fn child_family(child: &PkFlipChild, group: &PkTypeFlipGroup) -> PkFlipFamily {
if child.family as u8 == parent_family(group) as u8 {
child.family
} else {
parent_family(group)
}
}
fn render_on_delete(od: OnDeleteSchema) -> &'static str {
match od {
OnDeleteSchema::Restrict => "ON DELETE RESTRICT",
OnDeleteSchema::Cascade => "ON DELETE CASCADE",
OnDeleteSchema::SetNull => "ON DELETE SET NULL",
OnDeleteSchema::SetDefault => "ON DELETE SET DEFAULT",
OnDeleteSchema::NoAction => "ON DELETE NO ACTION",
}
}
fn render_autofill_trigger(
table: &str,
pairs: &[(&str, &str)],
family: PkFlipFamily,
direction: PkFlipDirection,
) -> String {
let flip_fn = flip_fn_name(family, direction);
let fn_name = format!("zzz_{}_autofill_desc", table);
let mut insert_body = String::new();
let mut update_body = String::new();
for (src, dst) in pairs {
let _ = writeln!(
insert_body,
" IF NEW.{dst} IS NULL THEN NEW.{dst} := {flip}(NEW.{src}); END IF;",
dst = dst,
flip = flip_fn,
src = src,
);
let _ = write!(
update_body,
" IF NEW.{src} IS DISTINCT FROM OLD.{src} THEN\n \
NEW.{dst} := {flip}(NEW.{src});\n ELSIF NEW.{dst} IS NULL THEN\n \
NEW.{dst} := {flip}(NEW.{src});\n END IF;\n",
src = src,
dst = dst,
flip = flip_fn,
);
}
format!(
"CREATE OR REPLACE FUNCTION {fn_name}() RETURNS trigger AS $body$\n\
BEGIN\n \
IF TG_OP = 'INSERT' THEN\n\
{insert_body} \
ELSIF TG_OP = 'UPDATE' THEN\n\
{update_body} \
END IF;\n \
RETURN NEW;\n\
END;\n\
$body$ LANGUAGE plpgsql;\n\n\
DROP TRIGGER IF EXISTS {fn_name} ON {table};\n\
CREATE TRIGGER {fn_name}\n \
BEFORE INSERT OR UPDATE ON {table}\n \
FOR EACH ROW EXECUTE FUNCTION {fn_name}();\n",
fn_name = fn_name,
insert_body = insert_body,
update_body = update_body,
table = table,
)
}
fn emit_preparation(group: &PkTypeFlipGroup) -> OperationSql {
emit_preparation_with_mode(group, EmitMode::Standard)
}
fn emit_preparation_with_mode(group: &PkTypeFlipGroup, mode: EmitMode) -> OperationSql {
let parent = group.parent_table.as_str();
let p_family = parent_family(group);
let direction = group.direction;
let id_type = pg_id_type(p_family);
let mut up = String::new();
let mut down = String::new();
if mode.includes_parent() {
let _ = writeln!(
up,
"ALTER TABLE {parent} ADD COLUMN id{suffix} {ty};",
parent = parent,
suffix = SHADOW_SUFFIX,
ty = id_type,
);
let _ = writeln!(
down,
"ALTER TABLE {parent} DROP COLUMN IF EXISTS id{suffix};",
parent = parent,
suffix = SHADOW_SUFFIX,
);
let mut self_pairs: Vec<(String, String)> = Vec::new();
self_pairs.push((PARENT_PK_COLUMN.to_string(), format!("id{}", SHADOW_SUFFIX)));
if let Some(self_fk) = &group.self_fk {
for (col, _cons) in self_fk
.fk_columns
.iter()
.zip(self_fk.fk_constraint_names.iter())
{
let dst = format!("{col}{suffix}", col = col, suffix = SHADOW_SUFFIX);
let _ = writeln!(
up,
"ALTER TABLE {parent} ADD COLUMN {dst} {ty};",
parent = parent,
dst = dst,
ty = id_type,
);
let _ = writeln!(
down,
"ALTER TABLE {parent} DROP COLUMN IF EXISTS {dst};",
parent = parent,
dst = dst,
);
self_pairs.push((col.clone(), dst));
}
}
let parent_pairs: Vec<(&str, &str)> = self_pairs
.iter()
.map(|(s, d)| (s.as_str(), d.as_str()))
.collect();
up.push_str(&render_autofill_trigger(
parent,
&parent_pairs,
p_family,
direction,
));
let _ = writeln!(
down,
"DROP TRIGGER IF EXISTS zzz_{parent}_autofill_desc ON {parent};",
parent = parent,
);
let _ = writeln!(
down,
"DROP FUNCTION IF EXISTS zzz_{parent}_autofill_desc() CASCADE;",
parent = parent,
);
}
for child in &group.children {
let cf = child_family(child, group);
let cty = pg_id_type(cf);
let dst = format!("{}{}", child.fk_column, SHADOW_SUFFIX);
let _ = writeln!(
up,
"ALTER TABLE {child_t} ADD COLUMN {dst} {ty};",
child_t = child.table,
dst = dst,
ty = cty,
);
up.push_str(&render_autofill_trigger(
&child.table,
&[(child.fk_column.as_str(), dst.as_str())],
cf,
direction,
));
let _ = writeln!(
down,
"DROP TRIGGER IF EXISTS zzz_{child_t}_autofill_desc ON {child_t};",
child_t = child.table,
);
let _ = writeln!(
down,
"DROP FUNCTION IF EXISTS zzz_{child_t}_autofill_desc() CASCADE;",
child_t = child.table,
);
let _ = writeln!(
down,
"ALTER TABLE {child_t} DROP COLUMN IF EXISTS {dst};",
child_t = child.table,
dst = dst,
);
}
for jt in &group.join_tables {
let pairs = jt_shadow_pairs(jt, group);
let id_type = pg_id_type(jt.family);
for pair in &pairs {
let dst = format!("{}{}", pair.col, SHADOW_SUFFIX);
let _ = writeln!(
up,
"ALTER TABLE {tbl} ADD COLUMN {dst} {ty};",
tbl = jt.table,
dst = dst,
ty = id_type,
);
}
let owned_pairs: Vec<(String, String)> = pairs
.iter()
.map(|p| (p.col.to_string(), format!("{}{}", p.col, SHADOW_SUFFIX)))
.collect();
let pair_refs: Vec<(&str, &str)> = owned_pairs
.iter()
.map(|(c, d)| (c.as_str(), d.as_str()))
.collect();
up.push_str(&render_autofill_trigger(
&jt.table, &pair_refs, jt.family, direction,
));
let _ = writeln!(
down,
"DROP TRIGGER IF EXISTS zzz_{tbl}_autofill_desc ON {tbl};",
tbl = jt.table,
);
let _ = writeln!(
down,
"DROP FUNCTION IF EXISTS zzz_{tbl}_autofill_desc() CASCADE;",
tbl = jt.table,
);
for pair in &pairs {
let dst = format!("{}{}", pair.col, SHADOW_SUFFIX);
let _ = writeln!(
down,
"ALTER TABLE {tbl} DROP COLUMN IF EXISTS {dst};",
tbl = jt.table,
dst = dst,
);
}
}
let label = match mode {
EmitMode::Standard => format!("PkFlipPrep {parent}"),
EmitMode::CascadeOnly => format!("PkFlipPrepCascade {parent}"),
};
OperationSql {
label,
up,
down,
lossy: None,
}
}
struct JoinTableShadowPair<'a> {
col: &'a str,
constraint: &'a str,
}
fn jt_shadow_pairs<'a>(
jt: &'a super::diff::PkFlipJoinTable,
group: &PkTypeFlipGroup,
) -> Vec<JoinTableShadowPair<'a>> {
let mut out = vec![JoinTableShadowPair {
col: jt.fk_to_parent_column.as_str(),
constraint: jt.fk_to_parent_constraint.as_str(),
}];
let cross_flipping = jt.fk_to_partner_column.is_some();
let option_a = matches!(
group.join_table_option,
super::diff::PkFlipJoinTableOption::OptionA
);
if cross_flipping
&& option_a
&& let (Some(pcol), Some(pcons)) = (
jt.fk_to_partner_column.as_ref(),
jt.fk_to_partner_constraint.as_ref(),
)
{
out.push(JoinTableShadowPair {
col: pcol.as_str(),
constraint: pcons.as_str(),
});
}
out
}
#[allow(dead_code)]
fn emit_backfill_and_verification(group: &PkTypeFlipGroup) -> OperationSql {
let parent = group.parent_table.as_str();
let p_family = parent_family(group);
let p_kind = backfill_kind_literal(p_family);
let mut up = String::new();
let _ = writeln!(
up,
"CALL heeranjid_bulk_backfill('{parent}', 'id', 'id{suffix}', {kind}, 10000);",
parent = parent,
suffix = SHADOW_SUFFIX,
kind = p_kind,
);
let _ = writeln!(
up,
"SELECT count(*) FROM {parent} WHERE id{suffix} IS NULL;",
parent = parent,
suffix = SHADOW_SUFFIX,
);
let _ = writeln!(
up,
"-- expect: 0 (verification halt point — runner aborts on count > 0)",
);
if let Some(self_fk) = &group.self_fk {
for col in &self_fk.fk_columns {
let dst = format!("{}{}", col, SHADOW_SUFFIX);
let _ = writeln!(
up,
"CALL heeranjid_bulk_backfill('{parent}', '{col}', '{dst}', {kind}, 10000);",
parent = parent,
col = col,
dst = dst,
kind = p_kind,
);
let _ = writeln!(
up,
"SELECT count(*) FROM {parent}\n \
WHERE ({col} IS NULL) IS DISTINCT FROM ({dst} IS NULL)\n \
OR ({col} IS NOT NULL AND {dst} <> {flip}({col}));",
parent = parent,
col = col,
dst = dst,
flip = flip_fn_name(p_family, group.direction),
);
let _ = writeln!(up, "-- expect: 0");
}
}
for child in &group.children {
let cf = child_family(child, group);
let dst = format!("{}{}", child.fk_column, SHADOW_SUFFIX);
let kind_lit = backfill_kind_literal(cf);
let _ = writeln!(
up,
"CALL heeranjid_bulk_backfill('{tbl}', '{src}', '{dst}', {kind}, 10000);",
tbl = child.table,
src = child.fk_column,
dst = dst,
kind = kind_lit,
);
if child.fk_nullable {
let _ = writeln!(
up,
"SELECT count(*) FROM {tbl}\n \
WHERE ({src} IS NULL) IS DISTINCT FROM ({dst} IS NULL)\n \
OR ({src} IS NOT NULL AND {dst} <> {flip}({src}));",
tbl = child.table,
src = child.fk_column,
dst = dst,
flip = flip_fn_name(cf, group.direction),
);
} else {
let _ = writeln!(
up,
"SELECT count(*) FROM {tbl} WHERE {dst} IS NULL;",
tbl = child.table,
dst = dst,
);
}
let _ = writeln!(up, "-- expect: 0");
let _ = writeln!(
up,
"ALTER TABLE {tbl} VALIDATE CONSTRAINT {tbl}_{dst}_fkey;",
tbl = child.table,
dst = dst,
);
}
for jt in &group.join_tables {
let kind_lit = backfill_kind_literal(jt.family);
for pair in jt_shadow_pairs(jt, group) {
let dst = format!("{}{}", pair.col, SHADOW_SUFFIX);
let _ = writeln!(
up,
"CALL heeranjid_bulk_backfill('{tbl}', '{src}', '{dst}', {kind}, 10000);",
tbl = jt.table,
src = pair.col,
dst = dst,
kind = kind_lit,
);
let _ = writeln!(
up,
"SELECT count(*) FROM {tbl}\n \
WHERE ({src} IS NULL) IS DISTINCT FROM ({dst} IS NULL)\n \
OR ({src} IS NOT NULL AND {dst} <> {flip}({src}));",
tbl = jt.table,
src = pair.col,
dst = dst,
flip = flip_fn_name(jt.family, group.direction),
);
let _ = writeln!(up, "-- expect: 0");
let _ = writeln!(
up,
"ALTER TABLE {tbl} VALIDATE CONSTRAINT {tbl}_{dst}_fkey;",
tbl = jt.table,
dst = dst,
);
}
}
OperationSql {
label: format!("PkFlipBackfill {parent}"),
up,
down: "-- Backfill is idempotent under `WHERE dst IS NULL`; the\n\
-- down side has no inverse beyond dropping the shadow\n\
-- column itself, which segment 1's down already covers."
.to_string(),
lossy: None,
}
}
fn emit_backfill_statements(group: &PkTypeFlipGroup) -> Vec<OperationSql> {
emit_backfill_statements_with_mode(group, EmitMode::Standard)
}
fn emit_backfill_statements_with_mode(
group: &PkTypeFlipGroup,
mode: EmitMode,
) -> Vec<OperationSql> {
let parent = group.parent_table.as_str();
let p_family = parent_family(group);
let direction = group.direction;
let mut out: Vec<OperationSql> = Vec::new();
let down_note = "-- Backfill is idempotent under `WHERE dst IS NULL`; the\n\
-- down side has no inverse beyond dropping the shadow\n\
-- column itself, which segment 1's down already covers."
.to_string();
if mode.includes_parent() {
out.push(OperationSql {
label: format!("PkFlipBackfill {parent}"),
up: emit_backfill_body(
parent,
"id",
&format!("id{}", SHADOW_SUFFIX),
p_family,
direction,
),
down: down_note.clone(),
lossy: None,
});
}
if let Some(self_fk) = &group.self_fk {
for col in &self_fk.fk_columns {
let dst = format!("{}{}", col, SHADOW_SUFFIX);
out.push(OperationSql {
label: format!("PkFlipBackfill {parent} {col}"),
up: emit_backfill_body(parent, col, &dst, p_family, direction),
down: down_note.clone(),
lossy: None,
});
}
}
for child in &group.children {
let cf = child_family(child, group);
let dst = format!("{}{}", child.fk_column, SHADOW_SUFFIX);
out.push(OperationSql {
label: format!("PkFlipBackfill {tbl}", tbl = child.table),
up: emit_backfill_body(&child.table, &child.fk_column, &dst, cf, direction),
down: down_note.clone(),
lossy: None,
});
}
for jt in &group.join_tables {
for pair in jt_shadow_pairs(jt, group) {
let dst = format!("{}{}", pair.col, SHADOW_SUFFIX);
out.push(OperationSql {
label: format!("PkFlipBackfill {tbl} {col}", tbl = jt.table, col = pair.col,),
up: emit_backfill_body(&jt.table, pair.col, &dst, jt.family, direction),
down: down_note.clone(),
lossy: None,
});
}
}
out
}
fn emit_backfill_body(
table: &str,
src_col: &str,
dst_col: &str,
family: PkFlipFamily,
direction: PkFlipDirection,
) -> String {
match direction {
PkFlipDirection::AscToDesc => {
let kind = backfill_kind_literal(family);
format!(
"CALL heeranjid_bulk_backfill('{table}', '{src}', '{dst}', {kind}, 10000)",
table = table,
src = src_col,
dst = dst_col,
kind = kind,
)
}
PkFlipDirection::DescToAsc => emit_reverse_backfill(table, src_col, dst_col, family),
}
}
fn emit_reverse_backfill(
table: &str,
src_col: &str,
dst_col: &str,
family: PkFlipFamily,
) -> String {
let flip_fn = match family {
PkFlipFamily::Heer => "heerid_to_asc",
PkFlipFamily::Ranj => "ranjid_to_asc",
};
format!(
"DO $$\n\
DECLARE\n \
rows_done int;\n\
BEGIN\n \
LOOP\n \
SET LOCAL lock_timeout = '30s';\n \
WITH batch AS (\n \
SELECT ctid FROM {table}\n \
WHERE {dst} IS NULL AND {src} IS NOT NULL\n \
LIMIT 10000\n \
FOR UPDATE SKIP LOCKED\n \
)\n \
UPDATE {table} t SET {dst} = {flip}(t.{src})\n \
FROM batch WHERE t.ctid = batch.ctid;\n \
GET DIAGNOSTICS rows_done = ROW_COUNT;\n \
COMMIT;\n \
EXIT WHEN rows_done = 0;\n \
END LOOP;\n \
LOOP\n \
SET LOCAL lock_timeout = '30s';\n \
WITH batch AS (\n \
SELECT ctid FROM {table}\n \
WHERE {dst} IS NULL AND {src} IS NOT NULL\n \
LIMIT 10000\n \
FOR UPDATE\n \
)\n \
UPDATE {table} t SET {dst} = {flip}(t.{src})\n \
FROM batch WHERE t.ctid = batch.ctid;\n \
GET DIAGNOSTICS rows_done = ROW_COUNT;\n \
COMMIT;\n \
EXIT WHEN rows_done = 0;\n \
END LOOP;\n\
END;\n\
$$",
table = table,
dst = dst_col,
src = src_col,
flip = flip_fn,
)
}
fn emit_verification_statements(group: &PkTypeFlipGroup) -> Vec<OperationSql> {
emit_verification_statements_with_mode(group, EmitMode::Standard)
}
fn emit_verification_statements_with_mode(
group: &PkTypeFlipGroup,
mode: EmitMode,
) -> Vec<OperationSql> {
let parent = group.parent_table.as_str();
let p_family = parent_family(group);
let mut out: Vec<OperationSql> = Vec::new();
if mode.includes_parent() {
out.push(OperationSql {
label: format!("PkFlipVerify {parent} pk-non-null"),
up: format!(
"SELECT count(*) FROM {parent} WHERE id{suffix} IS NULL",
parent = parent,
suffix = SHADOW_SUFFIX,
),
down: String::new(),
lossy: None,
});
}
if let Some(self_fk) = &group.self_fk {
for col in &self_fk.fk_columns {
let dst = format!("{}{}", col, SHADOW_SUFFIX);
out.push(OperationSql {
label: format!("PkFlipVerify {parent} {col}"),
up: format!(
"SELECT count(*) FROM {parent} \
WHERE ({col} IS NULL) IS DISTINCT FROM ({dst} IS NULL) \
OR ({col} IS NOT NULL AND {dst} <> {flip}({col}))",
parent = parent,
col = col,
dst = dst,
flip = flip_fn_name(p_family, group.direction),
),
down: String::new(),
lossy: None,
});
}
}
for child in &group.children {
let cf = child_family(child, group);
let dst = format!("{}{}", child.fk_column, SHADOW_SUFFIX);
if child.fk_nullable {
out.push(OperationSql {
label: format!(
"PkFlipVerify {tbl} {col}",
tbl = child.table,
col = child.fk_column
),
up: format!(
"SELECT count(*) FROM {tbl} \
WHERE ({src} IS NULL) IS DISTINCT FROM ({dst} IS NULL) \
OR ({src} IS NOT NULL AND {dst} <> {flip}({src}))",
tbl = child.table,
src = child.fk_column,
dst = dst,
flip = flip_fn_name(cf, group.direction),
),
down: String::new(),
lossy: None,
});
} else {
out.push(OperationSql {
label: format!(
"PkFlipVerify {tbl} {col}",
tbl = child.table,
col = child.fk_column
),
up: format!(
"SELECT count(*) FROM {tbl} WHERE {dst} IS NULL",
tbl = child.table,
dst = dst,
),
down: String::new(),
lossy: None,
});
}
}
for jt in &group.join_tables {
for pair in jt_shadow_pairs(jt, group) {
let dst = format!("{}{}", pair.col, SHADOW_SUFFIX);
out.push(OperationSql {
label: format!("PkFlipVerify {tbl} {col}", tbl = jt.table, col = pair.col),
up: format!(
"SELECT count(*) FROM {tbl} \
WHERE ({src} IS NULL) IS DISTINCT FROM ({dst} IS NULL) \
OR ({src} IS NOT NULL AND {dst} <> {flip}({src}))",
tbl = jt.table,
src = pair.col,
dst = dst,
flip = flip_fn_name(jt.family, group.direction),
),
down: String::new(),
lossy: None,
});
}
}
out
}
fn emit_child_fk_statements(group: &PkTypeFlipGroup) -> Vec<OperationSql> {
let parent = group.parent_table.as_str();
let mut out: Vec<OperationSql> = Vec::new();
if let Some(self_fk) = &group.self_fk {
for (i, col) in self_fk.fk_columns.iter().enumerate() {
let deferrable_clause = render_deferrable_clause(
self_fk.fk_deferrable.get(i).copied().unwrap_or(false),
self_fk
.fk_initially_deferred
.get(i)
.copied()
.unwrap_or(false),
);
out.push(OperationSql {
label: format!("PkFlipAddFk {parent} {col}"),
up: format!(
"ALTER TABLE {parent} ADD CONSTRAINT {parent}_{col}{suffix}_fkey \
FOREIGN KEY ({col}{suffix}) REFERENCES {parent}(id{suffix}){cycle} NOT VALID",
parent = parent,
col = col,
suffix = SHADOW_SUFFIX,
cycle = deferrable_clause,
),
down: format!(
"ALTER TABLE {parent} DROP CONSTRAINT IF EXISTS {parent}_{col}{suffix}_fkey",
parent = parent,
col = col,
suffix = SHADOW_SUFFIX,
),
lossy: None,
});
out.push(OperationSql {
label: format!("PkFlipValidateFk {parent} {col}"),
up: format!(
"ALTER TABLE {parent} VALIDATE CONSTRAINT {parent}_{col}{suffix}_fkey",
parent = parent,
col = col,
suffix = SHADOW_SUFFIX,
),
down: String::new(),
lossy: None,
});
}
}
for child in &group.children {
let dst = format!("{}{}", child.fk_column, SHADOW_SUFFIX);
let cycle_clause =
render_deferrable_clause(child.fk_deferrable, child.fk_initially_deferred);
out.push(OperationSql {
label: format!(
"PkFlipAddFk {tbl} {col}",
tbl = child.table,
col = child.fk_column
),
up: format!(
"ALTER TABLE {tbl} ADD CONSTRAINT {tbl}_{dst}_fkey \
FOREIGN KEY ({dst}) REFERENCES {parent}(id{suffix}){cycle} NOT VALID",
tbl = child.table,
dst = dst,
parent = parent,
suffix = SHADOW_SUFFIX,
cycle = cycle_clause,
),
down: format!(
"ALTER TABLE {tbl} DROP CONSTRAINT IF EXISTS {tbl}_{dst}_fkey",
tbl = child.table,
dst = dst,
),
lossy: None,
});
out.push(OperationSql {
label: format!(
"PkFlipValidateFk {tbl} {col}",
tbl = child.table,
col = child.fk_column
),
up: format!(
"ALTER TABLE {tbl} VALIDATE CONSTRAINT {tbl}_{dst}_fkey",
tbl = child.table,
dst = dst,
),
down: String::new(),
lossy: None,
});
}
for jt in &group.join_tables {
for pair in jt_shadow_pairs(jt, group) {
let dst = format!("{}{}", pair.col, SHADOW_SUFFIX);
let is_parent_side = pair.col == jt.fk_to_parent_column.as_str();
let target = if is_parent_side {
parent.to_string()
} else {
jt.fk_to_partner_table.clone().expect(
"PkFlipJoinTable invariant: fk_to_partner_table is Some \
whenever fk_to_partner_column is Some; jt_shadow_pairs \
only emits a partner-side pair when fk_to_partner_column \
is Some",
)
};
let (def, init_def) = if is_parent_side {
(
jt.fk_to_parent_deferrable,
jt.fk_to_parent_initially_deferred,
)
} else {
(
jt.fk_to_partner_deferrable,
jt.fk_to_partner_initially_deferred,
)
};
let deferrable_clause = render_deferrable_clause(def, init_def);
out.push(OperationSql {
label: format!("PkFlipAddFk {tbl} {col}", tbl = jt.table, col = pair.col),
up: format!(
"ALTER TABLE {tbl} ADD CONSTRAINT {tbl}_{dst}_fkey \
FOREIGN KEY ({dst}) REFERENCES {target}(id{suffix}){deferrable} NOT VALID",
tbl = jt.table,
dst = dst,
target = target,
suffix = SHADOW_SUFFIX,
deferrable = deferrable_clause,
),
down: format!(
"ALTER TABLE {tbl} DROP CONSTRAINT IF EXISTS {tbl}_{dst}_fkey",
tbl = jt.table,
dst = dst,
),
lossy: None,
});
out.push(OperationSql {
label: format!(
"PkFlipValidateFk {tbl} {col}",
tbl = jt.table,
col = pair.col,
),
up: format!(
"ALTER TABLE {tbl} VALIDATE CONSTRAINT {tbl}_{dst}_fkey",
tbl = jt.table,
dst = dst,
),
down: String::new(),
lossy: None,
});
}
}
out
}
fn emit_concurrent_index_statements(group: &PkTypeFlipGroup) -> Vec<OperationSql> {
emit_concurrent_index_statements_with_mode(group, EmitMode::Standard)
}
fn emit_concurrent_index_statements_with_mode(
group: &PkTypeFlipGroup,
mode: EmitMode,
) -> Vec<OperationSql> {
let parent = group.parent_table.as_str();
let mut out: Vec<OperationSql> = Vec::new();
if mode.includes_parent() {
out.push(OperationSql {
label: format!("PkFlipConcurrentIndex {parent}"),
up: format!(
"CREATE UNIQUE INDEX CONCURRENTLY idx_{parent}_id{suffix} ON {parent} (id{suffix})",
parent = parent,
suffix = SHADOW_SUFFIX,
),
down: format!(
"DROP INDEX IF EXISTS idx_{parent}_id{suffix}",
parent = parent,
suffix = SHADOW_SUFFIX,
),
lossy: None,
});
}
if group.partitioned_parent.is_none()
&& let Some(self_fk) = &group.self_fk
{
for col in &self_fk.fk_columns {
out.push(OperationSql {
label: format!("PkFlipConcurrentIndex {parent} {col}"),
up: format!(
"CREATE INDEX CONCURRENTLY idx_{parent}_{col}{suffix} ON {parent} ({col}{suffix})",
parent = parent,
col = col,
suffix = SHADOW_SUFFIX,
),
down: format!(
"DROP INDEX IF EXISTS idx_{parent}_{col}{suffix}",
parent = parent,
col = col,
suffix = SHADOW_SUFFIX,
),
lossy: None,
});
}
}
for child in &group.children {
let dst = format!("{}{}", child.fk_column, SHADOW_SUFFIX);
let unique_kw = if child.fk_unique { "UNIQUE " } else { "" };
out.push(OperationSql {
label: format!(
"PkFlipConcurrentIndex {tbl} {col}",
tbl = child.table,
col = child.fk_column
),
up: format!(
"CREATE {uniq}INDEX CONCURRENTLY idx_{tbl}_{dst} ON {tbl} ({dst})",
uniq = unique_kw,
tbl = child.table,
dst = dst,
),
down: format!(
"DROP INDEX IF EXISTS idx_{tbl}_{dst}",
tbl = child.table,
dst = dst,
),
lossy: None,
});
}
for jt in &group.join_tables {
for pair in jt_shadow_pairs(jt, group) {
let dst = format!("{}{}", pair.col, SHADOW_SUFFIX);
out.push(OperationSql {
label: format!(
"PkFlipConcurrentIndex {tbl} {col}",
tbl = jt.table,
col = pair.col,
),
up: format!(
"CREATE INDEX CONCURRENTLY idx_{tbl}_{dst} ON {tbl} ({dst})",
tbl = jt.table,
dst = dst,
),
down: format!(
"DROP INDEX IF EXISTS idx_{tbl}_{dst}",
tbl = jt.table,
dst = dst,
),
lossy: None,
});
}
}
out
}
#[allow(dead_code)]
fn emit_concurrent_indexes(group: &PkTypeFlipGroup) -> OperationSql {
let parent = group.parent_table.as_str();
let mut up = String::new();
let mut down = String::new();
let _ = writeln!(
up,
"CREATE UNIQUE INDEX CONCURRENTLY idx_{parent}_id{suffix} ON {parent} (id{suffix});",
parent = parent,
suffix = SHADOW_SUFFIX,
);
let _ = writeln!(
down,
"DROP INDEX IF EXISTS idx_{parent}_id{suffix};",
parent = parent,
suffix = SHADOW_SUFFIX,
);
if let Some(self_fk) = &group.self_fk {
for col in &self_fk.fk_columns {
let _ = writeln!(
up,
"CREATE INDEX CONCURRENTLY idx_{parent}_{col}{suffix} ON {parent} ({col}{suffix});",
parent = parent,
col = col,
suffix = SHADOW_SUFFIX,
);
let _ = writeln!(
down,
"DROP INDEX IF EXISTS idx_{parent}_{col}{suffix};",
parent = parent,
col = col,
suffix = SHADOW_SUFFIX,
);
}
}
for child in &group.children {
let dst = format!("{}{}", child.fk_column, SHADOW_SUFFIX);
let unique_kw = if child.fk_unique { "UNIQUE " } else { "" };
let _ = writeln!(
up,
"CREATE {uniq}INDEX CONCURRENTLY idx_{tbl}_{dst} ON {tbl} ({dst});",
uniq = unique_kw,
tbl = child.table,
dst = dst,
);
let _ = writeln!(
down,
"DROP INDEX IF EXISTS idx_{tbl}_{dst};",
tbl = child.table,
dst = dst,
);
}
for jt in &group.join_tables {
for pair in jt_shadow_pairs(jt, group) {
let dst = format!("{}{}", pair.col, SHADOW_SUFFIX);
let _ = writeln!(
up,
"CREATE INDEX CONCURRENTLY idx_{tbl}_{dst} ON {tbl} ({dst});",
tbl = jt.table,
dst = dst,
);
let _ = writeln!(
down,
"DROP INDEX IF EXISTS idx_{tbl}_{dst};",
tbl = jt.table,
dst = dst,
);
}
}
OperationSql {
label: format!("PkFlipConcurrentIndex {parent}"),
up,
down,
lossy: None,
}
}
fn emit_not_null_proof(group: &PkTypeFlipGroup) -> OperationSql {
let parent = group.parent_table.as_str();
let mut up = String::new();
let mut down = String::new();
let _ = writeln!(
up,
"ALTER TABLE {parent} ADD CONSTRAINT {parent}_id{suffix}_nn \
CHECK (id{suffix} IS NOT NULL) NOT VALID;",
parent = parent,
suffix = SHADOW_SUFFIX,
);
let _ = writeln!(
up,
"ALTER TABLE {parent} VALIDATE CONSTRAINT {parent}_id{suffix}_nn;",
parent = parent,
suffix = SHADOW_SUFFIX,
);
let _ = writeln!(
up,
"ALTER TABLE {parent} ALTER COLUMN id{suffix} SET NOT NULL;",
parent = parent,
suffix = SHADOW_SUFFIX,
);
let _ = writeln!(
up,
"ALTER TABLE {parent} DROP CONSTRAINT {parent}_id{suffix}_nn;",
parent = parent,
suffix = SHADOW_SUFFIX,
);
for child in &group.children {
if child.fk_nullable {
continue;
}
let dst = format!("{}{}", child.fk_column, SHADOW_SUFFIX);
let _ = writeln!(
up,
"ALTER TABLE {tbl} ADD CONSTRAINT {tbl}_{dst}_nn \
CHECK ({dst} IS NOT NULL) NOT VALID;",
tbl = child.table,
dst = dst,
);
let _ = writeln!(
up,
"ALTER TABLE {tbl} VALIDATE CONSTRAINT {tbl}_{dst}_nn;",
tbl = child.table,
dst = dst,
);
let _ = writeln!(
up,
"ALTER TABLE {tbl} ALTER COLUMN {dst} SET NOT NULL;",
tbl = child.table,
dst = dst,
);
let _ = writeln!(
up,
"ALTER TABLE {tbl} DROP CONSTRAINT {tbl}_{dst}_nn;",
tbl = child.table,
dst = dst,
);
}
let _ = writeln!(
down,
"ALTER TABLE {parent} ALTER COLUMN id{suffix} DROP NOT NULL;",
parent = parent,
suffix = SHADOW_SUFFIX,
);
for child in &group.children {
if child.fk_nullable {
continue;
}
let dst = format!("{}{}", child.fk_column, SHADOW_SUFFIX);
let _ = writeln!(
down,
"ALTER TABLE {tbl} ALTER COLUMN {dst} DROP NOT NULL;",
tbl = child.table,
dst = dst,
);
}
OperationSql {
label: format!("PkFlipNotNullProof {parent}"),
up,
down,
lossy: None,
}
}
fn emit_cutover(group: &PkTypeFlipGroup) -> OperationSql {
let parent = group.parent_table.as_str();
let mut up = String::new();
if !group.cycles.is_empty() {
up.push_str("SET CONSTRAINTS ALL DEFERRED;\n");
}
cutover_phase_drop_old_fks(group, &mut up);
cutover_phase_promote_parent(group, &mut up);
cutover_phase_finalise_children(group, &mut up);
cutover_phase_finalise_join_tables(group, &mut up);
let down = format!(
"-- POINT OF NO RETURN — segment 5 (cutover) for {parent} cannot be\n\
-- reversed by `down` SQL alone. Rollback requires an inverse\n\
-- migration: add the previous-direction column back, install a\n\
-- reverse autofill trigger, re-run heeranjid_bulk_backfill, and\n\
-- run a second cutover. Plan that contingency BEFORE running\n\
-- the forward cutover.",
parent = parent,
);
OperationSql {
label: format!("PkFlipCutover {parent}"),
up,
down,
lossy: Some(LossyRollbackWarning {
kind: LossyRollbackKind::PkTypeFlipPostCutover,
detail: format!(
"POINT OF NO RETURN: cutover for `{parent}` removes the prior PK column \
and trigger; rollback requires an inverse migration",
),
}),
}
}
fn cutover_drop_constraint(up: &mut String, table: &str, constraint: &str) {
let _ = writeln!(up, "ALTER TABLE {table} DROP CONSTRAINT {constraint};");
}
fn cutover_add_fk_constraint(
up: &mut String,
table: &str,
constraint: &str,
column: &str,
target: &str,
target_column: &str,
trailing_clause: &str,
) {
let _ = writeln!(
up,
"ALTER TABLE {table} ADD CONSTRAINT {constraint} \
FOREIGN KEY ({column}) REFERENCES {target}({target_column}){trailing_clause};",
);
}
fn cutover_phase_drop_old_fks(group: &PkTypeFlipGroup, up: &mut String) {
let parent = group.parent_table.as_str();
for child in &group.children {
cutover_drop_constraint(up, &child.table, &child.fk_constraint_name);
}
for jt in &group.join_tables {
for pair in jt_shadow_pairs(jt, group) {
cutover_drop_constraint(up, &jt.table, pair.constraint);
}
}
if let Some(self_fk) = &group.self_fk {
for cons in &self_fk.fk_constraint_names {
cutover_drop_constraint(up, parent, cons);
}
}
}
fn cutover_phase_promote_parent(group: &PkTypeFlipGroup, up: &mut String) {
let parent = group.parent_table.as_str();
let p_family = parent_family(group);
let direction = group.direction;
let next_fn = next_fn_name(p_family, direction);
let _ = writeln!(
up,
"ALTER TABLE {parent} DROP CONSTRAINT {parent}_pkey;",
parent = parent,
);
let _ = writeln!(
up,
"ALTER TABLE {parent} ADD CONSTRAINT {parent}_pkey \
PRIMARY KEY USING INDEX idx_{parent}_id{suffix};",
parent = parent,
suffix = SHADOW_SUFFIX,
);
let _ = writeln!(
up,
"ALTER TABLE {parent} ALTER COLUMN id{suffix} SET DEFAULT {next}();",
parent = parent,
suffix = SHADOW_SUFFIX,
next = next_fn,
);
let _ = writeln!(
up,
"ALTER TABLE {parent} ALTER COLUMN id DROP DEFAULT;",
parent = parent,
);
let _ = writeln!(up, "ALTER TABLE {parent} DROP COLUMN id;", parent = parent);
if let Some(self_fk) = &group.self_fk {
for col in &self_fk.fk_columns {
let _ = writeln!(
up,
"ALTER TABLE {parent} DROP COLUMN {col};",
parent = parent,
col = col,
);
}
}
let _ = writeln!(
up,
"DROP TRIGGER zzz_{parent}_autofill_desc ON {parent};",
parent = parent,
);
let _ = writeln!(
up,
"DROP FUNCTION zzz_{parent}_autofill_desc() CASCADE;",
parent = parent,
);
let _ = writeln!(
up,
"ALTER TABLE {parent} RENAME COLUMN id{suffix} TO id;",
parent = parent,
suffix = SHADOW_SUFFIX,
);
if let Some(self_fk) = &group.self_fk {
for col in &self_fk.fk_columns {
let constraint = format!("{parent}_{col}{suffix}_fkey", suffix = SHADOW_SUFFIX);
cutover_drop_constraint(up, parent, &constraint);
}
for col in &self_fk.fk_columns {
let _ = writeln!(
up,
"ALTER TABLE {parent} RENAME COLUMN {col}{suffix} TO {col};",
parent = parent,
col = col,
suffix = SHADOW_SUFFIX,
);
}
for (i, (col, cons)) in self_fk
.fk_columns
.iter()
.zip(self_fk.fk_constraint_names.iter())
.enumerate()
{
let deferrable_clause = render_deferrable_clause(
self_fk.fk_deferrable.get(i).copied().unwrap_or(false),
self_fk
.fk_initially_deferred
.get(i)
.copied()
.unwrap_or(false),
);
cutover_add_fk_constraint(up, parent, cons, col, parent, "id", deferrable_clause);
}
}
}
fn cutover_phase_finalise_children(group: &PkTypeFlipGroup, up: &mut String) {
let parent = group.parent_table.as_str();
for child in &group.children {
let dst = format!("{}{}", child.fk_column, SHADOW_SUFFIX);
let _ = writeln!(
up,
"ALTER TABLE {tbl} DROP COLUMN {col};",
tbl = child.table,
col = child.fk_column,
);
let _ = writeln!(
up,
"DROP TRIGGER zzz_{tbl}_autofill_desc ON {tbl};",
tbl = child.table,
);
let _ = writeln!(
up,
"DROP FUNCTION zzz_{tbl}_autofill_desc() CASCADE;",
tbl = child.table,
);
let shadow_constraint = format!("{tbl}_{dst}_fkey", tbl = child.table);
cutover_drop_constraint(up, &child.table, &shadow_constraint);
let _ = writeln!(
up,
"ALTER TABLE {tbl} RENAME COLUMN {dst} TO {col};",
tbl = child.table,
dst = dst,
col = child.fk_column,
);
let cascade = render_on_delete(child.on_delete);
let deferrable_clause =
render_deferrable_clause(child.fk_deferrable, child.fk_initially_deferred);
let trailing_clause = format!(" {cascade}{deferrable_clause}");
cutover_add_fk_constraint(
up,
&child.table,
&child.fk_constraint_name,
&child.fk_column,
parent,
"id",
&trailing_clause,
);
}
}
fn render_deferrable_clause(deferrable: bool, initially_deferred: bool) -> &'static str {
match (deferrable, initially_deferred) {
(false, _) => "",
(true, true) => " DEFERRABLE INITIALLY DEFERRED",
(true, false) => " DEFERRABLE INITIALLY IMMEDIATE",
}
}
fn cutover_phase_finalise_join_tables(group: &PkTypeFlipGroup, up: &mut String) {
let parent = group.parent_table.as_str();
for jt in &group.join_tables {
let pairs = jt_shadow_pairs(jt, group);
for pair in &pairs {
let _ = writeln!(
up,
"ALTER TABLE {tbl} DROP COLUMN {col};",
tbl = jt.table,
col = pair.col,
);
}
let _ = writeln!(
up,
"DROP TRIGGER zzz_{tbl}_autofill_desc ON {tbl};",
tbl = jt.table,
);
let _ = writeln!(
up,
"DROP FUNCTION zzz_{tbl}_autofill_desc() CASCADE;",
tbl = jt.table,
);
for pair in &pairs {
let dst = format!("{}{}", pair.col, SHADOW_SUFFIX);
let shadow_constraint = format!("{tbl}_{dst}_fkey", tbl = jt.table);
cutover_drop_constraint(up, &jt.table, &shadow_constraint);
}
for pair in &pairs {
let dst = format!("{}{}", pair.col, SHADOW_SUFFIX);
let _ = writeln!(
up,
"ALTER TABLE {tbl} RENAME COLUMN {dst} TO {col};",
tbl = jt.table,
dst = dst,
col = pair.col,
);
}
for pair in &pairs {
let is_parent_side = pair.col == jt.fk_to_parent_column.as_str();
let target_parent = if is_parent_side {
parent.to_string()
} else {
jt.fk_to_partner_table.clone().expect(
"PkFlipJoinTable invariant: fk_to_partner_table is Some \
whenever fk_to_partner_column is Some; jt_shadow_pairs \
only emits a partner-side pair when fk_to_partner_column \
is Some",
)
};
let (def, init_def) = if is_parent_side {
(
jt.fk_to_parent_deferrable,
jt.fk_to_parent_initially_deferred,
)
} else {
(
jt.fk_to_partner_deferrable,
jt.fk_to_partner_initially_deferred,
)
};
let deferrable_clause = render_deferrable_clause(def, init_def);
cutover_add_fk_constraint(
up,
&jt.table,
pair.constraint,
pair.col,
&target_parent,
"id",
deferrable_clause,
);
}
let layout_label = match group.join_table_option {
crate::migrate::diff::PkFlipJoinTableOption::OptionA => "OptionA",
crate::migrate::diff::PkFlipJoinTableOption::OptionB => "OptionB",
};
let _ = writeln!(
up,
"-- Join-table layout: {layout_label} (parent={parent}, join_table={tbl})",
tbl = jt.table,
);
}
}
fn emit_partitioned_preparation(
group: &PkTypeFlipGroup,
_part: &super::diff::PkFlipPartitionedMeta,
) -> OperationSql {
let parent = group.parent_table.as_str();
let p_family = parent_family(group);
let direction = group.direction;
let id_type = pg_id_type(p_family);
let mut up = String::new();
let mut down = String::new();
let _ = writeln!(
up,
"ALTER TABLE {parent} ADD COLUMN id{suffix} {ty};",
parent = parent,
suffix = SHADOW_SUFFIX,
ty = id_type,
);
let _ = writeln!(
down,
"ALTER TABLE {parent} DROP COLUMN IF EXISTS id{suffix};",
parent = parent,
suffix = SHADOW_SUFFIX,
);
let mut self_pairs: Vec<(String, String)> = Vec::new();
self_pairs.push((PARENT_PK_COLUMN.to_string(), format!("id{}", SHADOW_SUFFIX)));
if let Some(self_fk) = &group.self_fk {
for col in &self_fk.fk_columns {
let dst = format!("{col}{suffix}", col = col, suffix = SHADOW_SUFFIX);
let _ = writeln!(
up,
"ALTER TABLE {parent} ADD COLUMN {dst} {ty};",
parent = parent,
dst = dst,
ty = id_type,
);
let _ = writeln!(
down,
"ALTER TABLE {parent} DROP COLUMN IF EXISTS {dst};",
parent = parent,
dst = dst,
);
self_pairs.push((col.clone(), dst));
}
}
let parent_pairs: Vec<(&str, &str)> = self_pairs
.iter()
.map(|(s, d)| (s.as_str(), d.as_str()))
.collect();
up.push_str(&render_autofill_trigger(
parent,
&parent_pairs,
p_family,
direction,
));
let _ = writeln!(
down,
"DROP TRIGGER IF EXISTS zzz_{parent}_autofill_desc ON {parent};",
parent = parent,
);
let _ = writeln!(
down,
"DROP FUNCTION IF EXISTS zzz_{parent}_autofill_desc() CASCADE;",
parent = parent,
);
OperationSql {
label: format!("PkFlipPartitionedPrep {parent}"),
up,
down,
lossy: None,
}
}
fn emit_partitioned_backfill_only(
group: &PkTypeFlipGroup,
_part: &super::diff::PkFlipPartitionedMeta,
) -> OperationSql {
let parent = group.parent_table.as_str();
let p_family = parent_family(group);
let direction = group.direction;
let mut up = String::new();
let _ = writeln!(
up,
"-- Partitioned parent: invoke the backfill primitive once per leaf\n\
-- partition. The runner enumerates leaves from pg_inherits at apply\n\
-- time and emits one statement per leaf in deterministic\n\
-- regclass::text order, replacing the <EACH_LEAF_TABLE> placeholder\n\
-- with the concrete partition name. Operators hand-running this\n\
-- file MUST expand the placeholder themselves before executing.",
);
let body = emit_backfill_body(
"<EACH_LEAF_TABLE>",
"id",
&format!("id{}", SHADOW_SUFFIX),
p_family,
direction,
);
let _ = writeln!(up, "{body};", body = body);
OperationSql {
label: format!("PkFlipPartitionedBackfill {parent}"),
up,
down: "-- Partitioned backfill is idempotent under `WHERE dst IS NULL`;\n\
-- the down side has no inverse beyond dropping the shadow column."
.to_string(),
lossy: None,
}
}
fn emit_partitioned_verify(group: &PkTypeFlipGroup) -> OperationSql {
let parent = group.parent_table.as_str();
OperationSql {
label: format!("PkFlipVerify {parent} pk-non-null-aggregate"),
up: format!(
"SELECT count(*) FROM {parent} WHERE id{suffix} IS NULL",
parent = parent,
suffix = SHADOW_SUFFIX,
),
down: String::new(),
lossy: None,
}
}
fn emit_partitioned_indexes(
group: &PkTypeFlipGroup,
_part: &super::diff::PkFlipPartitionedMeta,
) -> OperationSql {
let parent = group.parent_table.as_str();
let part_col = match &group.partitioned_parent {
Some(meta) => match &meta.partition {
PartitionSchema::Range { column } => column.clone(),
PartitionSchema::Hash { column, .. } => column.clone(),
},
None => "partition_key".to_string(),
};
let mut up = String::new();
let mut down = String::new();
let _ = writeln!(
up,
"CREATE UNIQUE INDEX {parent}_{pkey}_id{suffix}_idx\n \
ON ONLY {parent} ({pkey}, id{suffix});",
parent = parent,
pkey = part_col,
suffix = SHADOW_SUFFIX,
);
let _ = writeln!(
up,
"-- Per leaf: CREATE UNIQUE INDEX CONCURRENTLY <leaf>_{pkey}_id{suffix}_idx\n\
-- ON <leaf> ({pkey}, id{suffix});\n\
-- Then ALTER INDEX {parent}_{pkey}_id{suffix}_idx ATTACH PARTITION\n\
-- <leaf>_{pkey}_id{suffix}_idx;\n\
-- The runner enumerates leaves from pg_inherits and emits these\n\
-- per-leaf statements at apply time.",
pkey = part_col,
suffix = SHADOW_SUFFIX,
parent = parent,
);
let _ = writeln!(
down,
"DROP INDEX IF EXISTS {parent}_{pkey}_id{suffix}_idx;",
parent = parent,
pkey = part_col,
suffix = SHADOW_SUFFIX,
);
OperationSql {
label: format!("PkFlipPartitionedIndex {parent}"),
up,
down,
lossy: None,
}
}
fn emit_partitioned_self_fk_indexes(group: &PkTypeFlipGroup) -> Vec<OperationSql> {
let mut out: Vec<OperationSql> = Vec::new();
if group.partitioned_parent.is_none() {
return out;
}
let Some(self_fk) = &group.self_fk else {
return out;
};
let parent = group.parent_table.as_str();
for col in &self_fk.fk_columns {
let mut up = String::new();
let _ = writeln!(
up,
"CREATE INDEX idx_{parent}_{col}{suffix}\n \
ON ONLY {parent} ({col}{suffix});",
parent = parent,
col = col,
suffix = SHADOW_SUFFIX,
);
let _ = writeln!(
up,
"-- Per leaf: CREATE INDEX CONCURRENTLY <leaf>_{col}{suffix}_idx\n\
-- ON <leaf> ({col}{suffix});\n\
-- Then ALTER INDEX idx_{parent}_{col}{suffix} ATTACH PARTITION\n\
-- <leaf>_{col}{suffix}_idx;\n\
-- The runner enumerates leaves from pg_inherits and emits these\n\
-- per-leaf statements at apply time.",
parent = parent,
col = col,
suffix = SHADOW_SUFFIX,
);
let down = format!(
"DROP INDEX IF EXISTS idx_{parent}_{col}{suffix};",
parent = parent,
col = col,
suffix = SHADOW_SUFFIX,
);
out.push(OperationSql {
label: format!("PkFlipPartitionedSelfFkIndex {parent}"),
up,
down,
lossy: None,
});
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::migrate::diff::{
Classification, EnumVariantAnchor, PkFlipCycle, PkFlipJoinTable, PkFlipSelfFk,
PkTypeFlipGroup, SchemaDelta, SchemaOperation, diff_bucket_maps,
};
use crate::migrate::projection::BucketKey;
use crate::migrate::schema::{
AppliedSchema, ColumnSchema, ForeignKeySchema, IndexSchema, OnDeleteSchema, PkKindSchema,
PrimaryKeySchema, RelationKindSchema, SNAPSHOT_FORMAT_VERSION, TableSchema,
};
use std::collections::BTreeMap;
fn _silence_unused() {
let _: Option<EnumVariantAnchor> = None;
let _: Option<IndexSchema> = None;
}
fn empty_schema() -> AppliedSchema {
AppliedSchema {
djogi_version: String::new(),
enums: BTreeMap::new(),
format_version: SNAPSHOT_FORMAT_VERSION.to_string(),
generated_at: String::new(),
indexes: Vec::new(),
models: BTreeMap::new(),
registered_apps: Vec::new(),
}
}
fn id_col() -> ColumnSchema {
ColumnSchema {
check: None,
comment: None,
default_sql: Some("heerid_next()".to_string()),
foreign_key: None,
generated: None,
identity: None,
index_type: None,
indexed: false,
max_length: None,
name: "id".to_string(),
nullable: false,
on_delete: None,
outbox_exclude: false,
rationale: None,
relation_kind: None,
renamed_from: None,
sequence_within: None,
sql_type: "BIGINT".to_string(),
unique: false,
type_change_using: None,
}
}
fn id_col_desc() -> ColumnSchema {
ColumnSchema {
default_sql: Some("heerid_next_desc()".to_string()),
..id_col()
}
}
fn fk_col(name: &str, target: &str, nullable: bool) -> ColumnSchema {
ColumnSchema {
check: None,
comment: None,
default_sql: None,
foreign_key: Some(ForeignKeySchema {
deferrable: false,
initially_deferred: false,
on_delete: OnDeleteSchema::Restrict,
ref_column: "id".to_string(),
ref_table: target.to_string(),
}),
generated: None,
identity: None,
index_type: None,
indexed: true,
max_length: None,
name: name.to_string(),
nullable,
on_delete: Some(OnDeleteSchema::Restrict),
outbox_exclude: false,
rationale: None,
relation_kind: Some(RelationKindSchema::ForeignKey),
renamed_from: None,
sequence_within: None,
sql_type: "BIGINT".to_string(),
unique: false,
type_change_using: None,
}
}
fn parent_table(name: &str, kind: PkKindSchema) -> TableSchema {
let cols = vec![if matches!(kind, PkKindSchema::HeerIdRecencyBiased) {
id_col_desc()
} else {
id_col()
}];
TableSchema {
app: None,
columns: cols,
exclusion_constraints: Vec::new(),
fts: None,
is_through: false,
moved_from_app: None,
partition: None,
primary_key: PrimaryKeySchema {
columns: vec!["id".to_string()],
kind,
},
rationale: None,
renamed_from: None,
rls_enabled: false,
table: name.to_string(),
table_comment: None,
storage_params: None,
tablespace: None,
tenant_key: None,
}
}
fn child_table(
name: &str,
fk_target: &str,
fk_col_name: &str,
fk_nullable: bool,
) -> TableSchema {
TableSchema {
app: None,
columns: vec![id_col(), fk_col(fk_col_name, fk_target, fk_nullable)],
exclusion_constraints: Vec::new(),
fts: None,
is_through: false,
moved_from_app: None,
partition: None,
primary_key: PrimaryKeySchema {
columns: vec!["id".to_string()],
kind: PkKindSchema::HeerId,
},
rationale: None,
renamed_from: None,
rls_enabled: false,
table: name.to_string(),
table_comment: None,
storage_params: None,
tablespace: None,
tenant_key: None,
}
}
fn bucket() -> BucketKey {
BucketKey {
database: "main".to_string(),
app: "".to_string(),
}
}
fn bucket_map(s: AppliedSchema) -> BTreeMap<BucketKey, AppliedSchema> {
let mut m = BTreeMap::new();
m.insert(bucket(), s);
m
}
#[test]
fn detects_all_four_pairs_via_diff_bucket_maps() {
for (from, to) in [
(PkKindSchema::HeerId, PkKindSchema::HeerIdRecencyBiased),
(PkKindSchema::HeerIdRecencyBiased, PkKindSchema::HeerId),
(PkKindSchema::RanjId, PkKindSchema::RanjIdRecencyBiased),
(PkKindSchema::RanjIdRecencyBiased, PkKindSchema::RanjId),
] {
let mut before = empty_schema();
before
.models
.insert("authors".to_string(), parent_table("authors", from.clone()));
let mut after = empty_schema();
after
.models
.insert("authors".to_string(), parent_table("authors", to.clone()));
let deltas = diff_bucket_maps(&bucket_map(before), &bucket_map(after)).expect("differ");
let group_op = deltas
.iter()
.flat_map(|d| d.operations.iter())
.find(|op| matches!(op, SchemaOperation::PkTypeFlipGroup(_)))
.expect("group op present");
if let SchemaOperation::PkTypeFlipGroup(g) = group_op {
assert_eq!(g.parent_table, "authors");
assert_eq!(g.parent_from, from);
assert_eq!(g.parent_to, to);
}
}
}
#[test]
fn non_flip_pk_change_not_misclassified() {
let mut before = empty_schema();
before
.models
.insert("t".to_string(), parent_table("t", PkKindSchema::HeerId));
let mut after = empty_schema();
after
.models
.insert("t".to_string(), parent_table("t", PkKindSchema::Serial));
let deltas = diff_bucket_maps(&bucket_map(before), &bucket_map(after)).expect("differ");
let has_group = deltas
.iter()
.flat_map(|d| d.operations.iter())
.any(|op| matches!(op, SchemaOperation::PkTypeFlipGroup(_)));
assert!(
!has_group,
"Serial transition must not produce a flip group"
);
let has_unsupported = deltas
.iter()
.flat_map(|d| d.operations.iter())
.any(|op| matches!(op, SchemaOperation::Unsupported { .. }));
assert!(has_unsupported);
}
#[test]
fn fk_cascade_grouping_collects_children() {
let mut before = empty_schema();
before.models.insert(
"authors".to_string(),
parent_table("authors", PkKindSchema::HeerId),
);
before.models.insert(
"books".to_string(),
child_table("books", "authors", "author_id", false),
);
let mut after = empty_schema();
after.models.insert(
"authors".to_string(),
parent_table("authors", PkKindSchema::HeerIdRecencyBiased),
);
after.models.insert(
"books".to_string(),
child_table("books", "authors", "author_id", false),
);
let deltas = diff_bucket_maps(&bucket_map(before), &bucket_map(after)).expect("differ");
let group = deltas
.iter()
.flat_map(|d| d.operations.iter())
.find_map(|op| match op {
SchemaOperation::PkTypeFlipGroup(g) => Some(g),
_ => None,
})
.expect("group present");
assert_eq!(group.children.len(), 1);
assert_eq!(group.children[0].table, "books");
assert_eq!(group.children[0].fk_column, "author_id");
assert_eq!(group.children[0].family, PkFlipFamily::Heer);
}
#[test]
fn self_fk_emits_multi_pair_trigger_metadata() {
let mut nodes = parent_table("nodes", PkKindSchema::HeerId);
nodes.columns.push(fk_col("parent_id", "nodes", true));
let mut before = empty_schema();
before.models.insert("nodes".to_string(), nodes.clone());
let mut after_nodes = parent_table("nodes", PkKindSchema::HeerIdRecencyBiased);
after_nodes.columns.push(fk_col("parent_id", "nodes", true));
let mut after = empty_schema();
after.models.insert("nodes".to_string(), after_nodes);
let deltas = diff_bucket_maps(&bucket_map(before), &bucket_map(after)).expect("differ");
let group = deltas
.iter()
.flat_map(|d| d.operations.iter())
.find_map(|op| match op {
SchemaOperation::PkTypeFlipGroup(g) => Some(g),
_ => None,
})
.expect("group present");
let self_fk = group.self_fk.as_ref().expect("self_fk present");
assert_eq!(self_fk.fk_columns, vec!["parent_id".to_string()]);
assert!(group.children.is_empty());
}
#[test]
fn join_table_grouping_detects_through_table() {
let book_tags = TableSchema {
app: None,
columns: vec![
fk_col("book_id", "books", false),
fk_col("tag_id", "tags", false),
],
exclusion_constraints: Vec::new(),
fts: None,
is_through: true,
moved_from_app: None,
partition: None,
primary_key: PrimaryKeySchema {
columns: vec!["book_id".to_string(), "tag_id".to_string()],
kind: PkKindSchema::Composite,
},
rationale: None,
renamed_from: None,
rls_enabled: false,
table: "book_tags".to_string(),
table_comment: None,
storage_params: None,
tablespace: None,
tenant_key: None,
};
let mut before = empty_schema();
before.models.insert(
"tags".to_string(),
parent_table("tags", PkKindSchema::HeerId),
);
before
.models
.insert("book_tags".to_string(), book_tags.clone());
let mut after = empty_schema();
after.models.insert(
"tags".to_string(),
parent_table("tags", PkKindSchema::HeerIdRecencyBiased),
);
after.models.insert("book_tags".to_string(), book_tags);
let deltas = diff_bucket_maps(&bucket_map(before), &bucket_map(after)).expect("differ");
let group = deltas
.iter()
.flat_map(|d| d.operations.iter())
.find_map(|op| match op {
SchemaOperation::PkTypeFlipGroup(g) => Some(g),
_ => None,
})
.expect("group present");
assert_eq!(group.join_tables.len(), 1);
assert_eq!(group.join_tables[0].table, "book_tags");
assert_eq!(group.join_tables[0].fk_to_parent_column, "tag_id");
}
#[test]
fn cycle_detection_via_mutual_fks() {
let mut a = parent_table("a", PkKindSchema::HeerId);
a.columns.push(fk_col("b_id", "b", true));
let mut b = parent_table("b", PkKindSchema::HeerId);
b.columns.push(fk_col("a_id", "a", true));
let mut before = empty_schema();
before.models.insert("a".to_string(), a.clone());
before.models.insert("b".to_string(), b.clone());
let mut after_a = parent_table("a", PkKindSchema::HeerIdRecencyBiased);
after_a.columns.push(fk_col("b_id", "b", true));
let mut after = empty_schema();
after.models.insert("a".to_string(), after_a);
after.models.insert("b".to_string(), b);
let deltas = diff_bucket_maps(&bucket_map(before), &bucket_map(after)).expect("differ");
let group = deltas
.iter()
.flat_map(|d| d.operations.iter())
.find_map(|op| match op {
SchemaOperation::PkTypeFlipGroup(g) => Some(g),
_ => None,
})
.expect("group present");
assert_eq!(group.cycles.len(), 1);
assert_eq!(group.cycles[0].peer_table, "b");
assert_eq!(group.cycles[0].peer_fk_column, "a_id");
assert_eq!(group.cycles[0].self_fk_column, "b_id");
}
fn synth_group_single_table() -> PkTypeFlipGroup {
PkTypeFlipGroup {
parent_table: "tbl".to_string(),
parent_from: PkKindSchema::HeerId,
parent_to: PkKindSchema::HeerIdRecencyBiased,
direction: PkFlipDirection::AscToDesc,
children: Vec::new(),
self_fk: None,
join_tables: Vec::new(),
cycles: Vec::new(),
partitioned_parent: None,
co_destructive: false,
co_lossy: false,
join_table_option: crate::migrate::diff::PkFlipJoinTableOption::OptionA,
}
}
fn whitespace_normalize(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut prev_was_ws = true;
for &b in s.as_bytes() {
let is_ws = matches!(b, b' ' | b'\t' | b'\n' | b'\r');
if is_ws {
if !prev_was_ws {
out.push(' ');
prev_was_ws = true;
}
} else {
out.push(b as char);
prev_was_ws = false;
}
}
out.trim().to_string()
}
#[test]
fn emitter_output_drift_check_section_3_preparation() {
let group = synth_group_single_table();
let prep = emit_preparation(&group);
let normalised = whitespace_normalize(&prep.up);
assert!(
normalised.contains("ALTER TABLE tbl ADD COLUMN id_desc bigint;"),
"missing §3.1 ADD COLUMN; got: {normalised}",
);
assert!(normalised.contains("zzz_tbl_autofill_desc"));
assert!(normalised.contains("heerid_to_desc(NEW.id)"));
}
#[test]
fn emitter_output_drift_check_section_3_backfill() {
let group = synth_group_single_table();
let bf = emit_backfill_and_verification(&group);
let n = whitespace_normalize(&bf.up);
assert!(
n.contains("CALL heeranjid_bulk_backfill('tbl', 'id', 'id_desc', 'heer', 10000);"),
"missing §3.2 CALL; got: {n}",
);
assert!(
n.contains("SELECT count(*) FROM tbl WHERE id_desc IS NULL;"),
"missing §3.3 verification; got: {n}",
);
}
#[test]
fn emitter_output_drift_check_section_3_concurrent_index() {
let group = synth_group_single_table();
let idx = emit_concurrent_indexes(&group);
let n = whitespace_normalize(&idx.up);
assert!(
n.contains("CREATE UNIQUE INDEX CONCURRENTLY idx_tbl_id_desc ON tbl (id_desc);"),
"missing §3.4 concurrent index; got: {n}",
);
}
#[test]
fn emitter_output_drift_check_section_3_not_null_proof() {
let group = synth_group_single_table();
let proof = emit_not_null_proof(&group);
let n = whitespace_normalize(&proof.up);
assert!(n.contains(
"ALTER TABLE tbl ADD CONSTRAINT tbl_id_desc_nn CHECK (id_desc IS NOT NULL) NOT VALID;"
));
assert!(n.contains("ALTER TABLE tbl VALIDATE CONSTRAINT tbl_id_desc_nn;"));
assert!(n.contains("ALTER TABLE tbl ALTER COLUMN id_desc SET NOT NULL;"));
assert!(n.contains("ALTER TABLE tbl DROP CONSTRAINT tbl_id_desc_nn;"));
}
#[test]
fn emitter_output_drift_check_section_3_cutover() {
let group = synth_group_single_table();
let cut = emit_cutover(&group);
let n = whitespace_normalize(&cut.up);
assert!(n.contains("ALTER TABLE tbl DROP CONSTRAINT tbl_pkey;"));
assert!(n.contains(
"ALTER TABLE tbl ADD CONSTRAINT tbl_pkey PRIMARY KEY USING INDEX idx_tbl_id_desc;"
));
assert!(n.contains("ALTER TABLE tbl ALTER COLUMN id_desc SET DEFAULT heerid_next_desc();"));
assert!(n.contains("ALTER TABLE tbl ALTER COLUMN id DROP DEFAULT;"));
assert!(n.contains("ALTER TABLE tbl DROP COLUMN id;"));
assert!(n.contains("DROP TRIGGER zzz_tbl_autofill_desc ON tbl;"));
assert!(n.contains("DROP FUNCTION zzz_tbl_autofill_desc() CASCADE;"));
assert!(n.contains("ALTER TABLE tbl RENAME COLUMN id_desc TO id;"));
assert!(
!n.contains("BEGIN;"),
"cutover body must not carry BEGIN; got: {n}"
);
assert!(
!n.contains("COMMIT;"),
"cutover body must not carry COMMIT; got: {n}"
);
let warn = cut.lossy.expect("cutover lossy warning");
assert_eq!(warn.kind, LossyRollbackKind::PkTypeFlipPostCutover);
}
#[test]
fn emitter_output_drift_check_section_4_parent_child() {
let mut group = synth_group_single_table();
group.children.push(PkFlipChild {
table: "c".to_string(),
fk_column: "p_id".to_string(),
fk_constraint_name: "c_p_id_fkey".to_string(),
on_delete: OnDeleteSchema::Restrict,
fk_deferrable: false,
fk_initially_deferred: false,
fk_nullable: false,
fk_unique: false,
family: PkFlipFamily::Heer,
cycle_flag: false,
});
let prep = emit_preparation(&group);
let nprep = whitespace_normalize(&prep.up);
assert!(nprep.contains("ALTER TABLE c ADD COLUMN p_id_desc bigint;"));
assert!(
!nprep.contains("ADD CONSTRAINT c_p_id_desc_fkey"),
"child FK creation belongs in segment 3b, not segment 1"
);
let fk_stmts = emit_child_fk_statements(&group);
let fk_text: String = fk_stmts
.iter()
.map(|s| s.up.as_str())
.collect::<Vec<_>>()
.join(";\n");
let nfk = whitespace_normalize(&fk_text);
assert!(nfk.contains(
"ALTER TABLE c ADD CONSTRAINT c_p_id_desc_fkey \
FOREIGN KEY (p_id_desc) REFERENCES tbl(id_desc) NOT VALID"
));
assert!(nfk.contains("ALTER TABLE c VALIDATE CONSTRAINT c_p_id_desc_fkey"));
let bf_stmts = emit_backfill_statements(&group);
let bf_text: String = bf_stmts
.iter()
.map(|s| s.up.as_str())
.collect::<Vec<_>>()
.join(";\n");
let nbf = whitespace_normalize(&bf_text);
assert!(
nbf.contains("CALL heeranjid_bulk_backfill('c', 'p_id', 'p_id_desc', 'heer', 10000)")
);
let cut = emit_cutover(&group);
let ncut = whitespace_normalize(&cut.up);
assert!(ncut.contains("ALTER TABLE c DROP CONSTRAINT c_p_id_fkey;"));
assert!(ncut.contains(
"ALTER TABLE c ADD CONSTRAINT c_p_id_fkey FOREIGN KEY (p_id) REFERENCES tbl(id) ON DELETE RESTRICT;"
));
}
#[test]
fn emitter_output_drift_check_section_6_self_fk() {
let mut group = synth_group_single_table();
group.parent_table = "nodes".to_string();
group.self_fk = Some(PkFlipSelfFk {
fk_columns: vec!["parent_id".to_string()],
fk_constraint_names: vec!["nodes_parent_id_fkey".to_string()],
fk_deferrable: vec![false],
fk_initially_deferred: vec![false],
});
let prep = emit_preparation(&group);
let n = whitespace_normalize(&prep.up);
assert!(n.contains("ALTER TABLE nodes ADD COLUMN id_desc bigint;"));
assert!(n.contains("ALTER TABLE nodes ADD COLUMN parent_id_desc bigint;"));
assert!(
!n.contains("ADD CONSTRAINT nodes_parent_id_desc_fkey"),
"self-FK constraint belongs in segment 3b"
);
assert!(n.contains("heerid_to_desc(NEW.id)"));
assert!(n.contains("heerid_to_desc(NEW.parent_id)"));
let fk_stmts = emit_child_fk_statements(&group);
let nfk = whitespace_normalize(
&fk_stmts
.iter()
.map(|s| s.up.as_str())
.collect::<Vec<_>>()
.join(";\n"),
);
assert!(nfk.contains(
"ALTER TABLE nodes ADD CONSTRAINT nodes_parent_id_desc_fkey \
FOREIGN KEY (parent_id_desc) REFERENCES nodes(id_desc) NOT VALID"
));
let cut = emit_cutover(&group);
let ncut = whitespace_normalize(&cut.up);
assert!(ncut.contains("ALTER TABLE nodes DROP CONSTRAINT nodes_parent_id_fkey;"));
assert!(ncut.contains("ALTER TABLE nodes DROP CONSTRAINT nodes_pkey;"));
assert!(ncut.contains(
"ALTER TABLE nodes ADD CONSTRAINT nodes_pkey PRIMARY KEY USING INDEX idx_nodes_id_desc;"
));
assert!(ncut.contains("ALTER TABLE nodes RENAME COLUMN parent_id_desc TO parent_id;"));
assert!(ncut.contains(
"ALTER TABLE nodes ADD CONSTRAINT nodes_parent_id_fkey FOREIGN KEY (parent_id) REFERENCES nodes(id);"
));
}
#[test]
fn emitter_output_drift_check_section_7_join_table() {
let mut group = synth_group_single_table();
group.parent_table = "tags".to_string();
group.join_tables.push(PkFlipJoinTable {
table: "book_tags".to_string(),
fk_to_parent_column: "tag_id".to_string(),
fk_to_parent_constraint: "book_tags_tag_id_fkey".to_string(),
fk_to_parent_deferrable: false,
fk_to_parent_initially_deferred: false,
fk_to_partner_column: None,
fk_to_partner_constraint: None,
fk_to_partner_table: None,
fk_to_partner_deferrable: false,
fk_to_partner_initially_deferred: false,
family: PkFlipFamily::Heer,
});
let prep = emit_preparation(&group);
let n = whitespace_normalize(&prep.up);
assert!(n.contains("ALTER TABLE book_tags ADD COLUMN tag_id_desc bigint;"));
assert!(n.contains("zzz_book_tags_autofill_desc"));
let bf = emit_backfill_and_verification(&group);
let nbf = whitespace_normalize(&bf.up);
assert!(nbf.contains(
"CALL heeranjid_bulk_backfill('book_tags', 'tag_id', 'tag_id_desc', 'heer', 10000);"
));
let cut = emit_cutover(&group);
let ncut = whitespace_normalize(&cut.up);
assert!(ncut.contains("ALTER TABLE book_tags DROP CONSTRAINT book_tags_tag_id_fkey;"));
assert!(ncut.contains("ALTER TABLE book_tags RENAME COLUMN tag_id_desc TO tag_id;"));
}
#[test]
fn emitter_output_drift_check_section_8_cycles_uses_deferrable() {
let mut group = synth_group_single_table();
group.parent_table = "a".to_string();
group.children.push(PkFlipChild {
table: "b".to_string(),
fk_column: "a_id".to_string(),
fk_constraint_name: "b_a_id_fkey".to_string(),
on_delete: OnDeleteSchema::Restrict,
fk_deferrable: true,
fk_initially_deferred: true,
fk_nullable: true,
fk_unique: false,
family: PkFlipFamily::Heer,
cycle_flag: true,
});
group.cycles.push(PkFlipCycle {
peer_table: "b".to_string(),
peer_fk_column: "a_id".to_string(),
self_fk_column: "b_id".to_string(),
});
let cut = emit_cutover(&group);
let n = whitespace_normalize(&cut.up);
assert!(
n.contains("SET CONSTRAINTS ALL DEFERRED;"),
"cycle cutover must defer constraints; got: {n}"
);
}
#[test]
fn emitter_output_drift_check_section_9_partitioned_uses_add_primary_key() {
let mut group = synth_group_single_table();
group.parent_table = "events".to_string();
group.partitioned_parent = Some(super::super::diff::PkFlipPartitionedMeta {
partition: PartitionSchema::Range {
column: "ts".to_string(),
},
});
let segments = build_segments(&group).expect("build_segments");
assert_eq!(segments.len(), 6, "expected 6 segments post-B-7 split");
let cut_stmt = &segments.last().expect("cutover segment").statements[0];
let n = whitespace_normalize(&cut_stmt.up);
assert!(
n.contains("ALTER TABLE events ADD PRIMARY KEY (ts, id_desc);"),
"partitioned cutover must use ADD PRIMARY KEY (...) form (not USING INDEX); got: {n}"
);
assert!(
!n.contains("BEGIN;"),
"partitioned cutover body must not carry BEGIN (B-9); got: {n}"
);
let verify_stmt = &segments[2].statements[0];
assert!(
verify_stmt.label.starts_with("PkFlipVerify "),
"expected verify segment with PkFlipVerify label; got: {}",
verify_stmt.label
);
let idx_stmt = &segments[3].statements[0];
let nidx = whitespace_normalize(&idx_stmt.up);
assert!(
nidx.contains(
"CREATE UNIQUE INDEX events_ts_id_desc_idx ON ONLY events (ts, id_desc);"
),
"partitioned index segment must emit ON ONLY parent placeholder; got: {nidx}"
);
}
#[test]
fn partitioned_prep_emits_self_fk_shadow_columns_and_multi_pair_trigger() {
let mut group = synth_group_single_table();
group.parent_table = "nodes".to_string();
group.self_fk = Some(PkFlipSelfFk {
fk_columns: vec!["parent_id".to_string()],
fk_constraint_names: vec!["nodes_parent_id_fkey".to_string()],
fk_deferrable: vec![false],
fk_initially_deferred: vec![false],
});
group.partitioned_parent = Some(super::super::diff::PkFlipPartitionedMeta {
partition: PartitionSchema::Range {
column: "ts".to_string(),
},
});
let part = group.partitioned_parent.as_ref().expect("part meta");
let prep = emit_partitioned_preparation(&group, part);
let n = whitespace_normalize(&prep.up);
assert!(
n.contains("ALTER TABLE nodes ADD COLUMN id_desc bigint;"),
"expected parent PK shadow ADD COLUMN; got: {n}"
);
assert!(
n.contains("ALTER TABLE nodes ADD COLUMN parent_id_desc bigint;"),
"expected self-FK shadow ADD COLUMN; got: {n}"
);
assert!(
n.contains("heerid_to_desc(NEW.id)"),
"expected multi-pair trigger arm for id; got: {n}"
);
assert!(
n.contains("heerid_to_desc(NEW.parent_id)"),
"expected multi-pair trigger arm for parent_id; got: {n}"
);
let nd = whitespace_normalize(&prep.down);
assert!(
nd.contains("ALTER TABLE nodes DROP COLUMN IF EXISTS id_desc;"),
"down side missing parent shadow drop; got: {nd}"
);
assert!(
nd.contains("ALTER TABLE nodes DROP COLUMN IF EXISTS parent_id_desc;"),
"down side missing self-FK shadow drop; got: {nd}"
);
}
#[test]
fn partitioned_self_fk_index_uses_on_only_not_concurrently_on_parent() {
let mut group = synth_group_single_table();
group.parent_table = "events".to_string();
group.self_fk = Some(PkFlipSelfFk {
fk_columns: vec!["origin_event_id".to_string()],
fk_constraint_names: vec!["events_origin_event_id_fkey".to_string()],
fk_deferrable: vec![false],
fk_initially_deferred: vec![false],
});
group.partitioned_parent = Some(super::super::diff::PkFlipPartitionedMeta {
partition: PartitionSchema::Range {
column: "ts".to_string(),
},
});
let segments = build_segments(&group).expect("build_segments");
let index_segment = &segments[3];
let index_bodies: Vec<&str> = index_segment
.statements
.iter()
.map(|s| s.up.as_str())
.collect();
let joined = index_bodies.join("\n");
let n = whitespace_normalize(&joined);
assert!(
!n.contains("CREATE INDEX CONCURRENTLY idx_events_origin_event_id_desc ON events"),
"self-FK index must not run CONCURRENTLY on partitioned parent; got: {n}"
);
assert!(
n.contains(
"CREATE INDEX idx_events_origin_event_id_desc ON ONLY events (origin_event_id_desc);"
),
"self-FK index must use ON ONLY parent form; got: {n}"
);
let labels: Vec<&str> = index_segment
.statements
.iter()
.map(|s| s.label.as_str())
.collect();
assert!(
labels
.iter()
.any(|l| l.starts_with("PkFlipPartitionedSelfFkIndex events")),
"expected PkFlipPartitionedSelfFkIndex label; got labels: {labels:?}"
);
}
#[test]
fn reverse_direction_sql_uses_to_asc_and_next() {
let group = PkTypeFlipGroup {
parent_table: "tbl".to_string(),
parent_from: PkKindSchema::HeerIdRecencyBiased,
parent_to: PkKindSchema::HeerId,
direction: PkFlipDirection::DescToAsc,
children: Vec::new(),
self_fk: None,
join_tables: Vec::new(),
cycles: Vec::new(),
partitioned_parent: None,
co_destructive: false,
co_lossy: false,
join_table_option: crate::migrate::diff::PkFlipJoinTableOption::OptionA,
};
let prep = emit_preparation(&group);
let np = whitespace_normalize(&prep.up);
assert!(np.contains("heerid_to_asc(NEW.id)"));
let cut = emit_cutover(&group);
let nc = whitespace_normalize(&cut.up);
assert!(nc.contains("ALTER TABLE tbl ALTER COLUMN id_desc SET DEFAULT heerid_next();"));
}
#[test]
fn lower_pk_flip_group_is_byte_stable() {
let group = synth_group_single_table();
let plan_a = lower_pk_flip_group(&group, bucket()).expect("lower pk flip group");
let plan_b = lower_pk_flip_group(&group, bucket()).expect("lower pk flip group");
assert_eq!(plan_a, plan_b);
}
#[test]
fn end_to_end_diff_to_plan_emits_six_segments_with_verification() {
let mut before = empty_schema();
before.models.insert(
"authors".to_string(),
parent_table("authors", PkKindSchema::HeerId),
);
let mut after = empty_schema();
after.models.insert(
"authors".to_string(),
parent_table("authors", PkKindSchema::HeerIdRecencyBiased),
);
let deltas = diff_bucket_maps(&bucket_map(before), &bucket_map(after)).expect("differ");
let group = deltas
.iter()
.flat_map(|d| d.operations.iter())
.find_map(|op| match op {
SchemaOperation::PkTypeFlipGroup(g) => Some(g),
_ => None,
})
.expect("group present");
let plan = lower_pk_flip_group(group, bucket()).expect("lower pk flip group");
assert_eq!(
plan.segments.len(),
6,
"single-table flip emits 6 segments (with verification); got {}",
plan.segments.len()
);
assert_eq!(plan.segments[0].kind, SegmentKind::Transactional);
assert_eq!(plan.segments[1].kind, SegmentKind::NonTransactional);
assert_eq!(plan.segments[2].kind, SegmentKind::Transactional); assert_eq!(plan.segments[3].kind, SegmentKind::NonTransactional);
assert_eq!(plan.segments[4].kind, SegmentKind::Transactional);
assert_eq!(plan.segments[5].kind, SegmentKind::Transactional);
for stmt in &plan.segments[2].statements {
assert!(
stmt.label.starts_with("PkFlipVerify "),
"verify segment label: {}",
stmt.label
);
}
let cut = &plan.segments[5].statements[0];
let warn = cut.lossy.as_ref().expect("cutover lossy");
assert_eq!(warn.kind, LossyRollbackKind::PkTypeFlipPostCutover);
}
fn _silence_classification_unused() {
let _ = Classification::PkTypeFlip {
co_destructive: false,
co_lossy: false,
};
let _ = SchemaDelta {
bucket: bucket(),
operations: Vec::new(),
classification: Classification::NoOp,
};
}
fn whole_plan_normalised(plan: &MigrationPlan) -> String {
let mut combined = String::new();
for seg in &plan.segments {
for stmt in &seg.statements {
combined.push_str(&stmt.up);
combined.push('\n');
}
}
whitespace_normalize(&combined)
}
fn lowered_plan_section_3() -> MigrationPlan {
let group = synth_group_single_table();
super::lower_pk_flip_group(&group, bucket()).expect("lower pk flip group")
}
const EMITTER_OUTPUT_SECTION_3_NORMALIZED: &str =
include_str!("fixtures/pk_flip_emitter_output_section_3.sql");
#[test]
fn dump_pk_flip_fixtures() {
if std::env::var("DJOGI_DUMP_PK_FLIP_FIXTURES").ok().as_deref() != Some("1") {
return;
}
let out_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/migrate/fixtures");
std::fs::create_dir_all(&out_dir).unwrap();
let write = |name: &str, body: String| {
let path = out_dir.join(name);
let mut s = body;
if !s.ends_with('\n') {
s.push('\n');
}
std::fs::write(path, s).unwrap();
};
let plan_3 = lowered_plan_section_3();
write(
"pk_flip_emitter_output_section_3.sql",
whole_plan_normalised(&plan_3),
);
let mut g3r = synth_group_single_table();
g3r.parent_from = PkKindSchema::HeerIdRecencyBiased;
g3r.parent_to = PkKindSchema::HeerId;
g3r.direction = PkFlipDirection::DescToAsc;
let plan_3r = super::lower_pk_flip_group(&g3r, bucket()).expect("lower pk flip group");
write(
"pk_flip_emitter_output_section_3_reverse.sql",
whole_plan_normalised(&plan_3r),
);
let mut g4 = synth_group_single_table();
g4.parent_table = "parent".to_string();
g4.children.push(PkFlipChild {
table: "c".to_string(),
fk_column: "p_id".to_string(),
fk_constraint_name: "c_p_id_fkey".to_string(),
on_delete: OnDeleteSchema::Restrict,
fk_deferrable: false,
fk_initially_deferred: false,
fk_nullable: false,
fk_unique: false,
family: PkFlipFamily::Heer,
cycle_flag: false,
});
let plan_4 = super::lower_pk_flip_group(&g4, bucket()).expect("lower pk flip group");
write(
"pk_flip_emitter_output_section_4.sql",
whole_plan_normalised(&plan_4),
);
let mut g6 = synth_group_single_table();
g6.parent_table = "nodes".to_string();
g6.self_fk = Some(PkFlipSelfFk {
fk_columns: vec!["parent_id".to_string()],
fk_constraint_names: vec!["nodes_parent_id_fkey".to_string()],
fk_deferrable: vec![false],
fk_initially_deferred: vec![false],
});
let plan_6 = super::lower_pk_flip_group(&g6, bucket()).expect("lower pk flip group");
write(
"pk_flip_emitter_output_section_6.sql",
whole_plan_normalised(&plan_6),
);
let mut g7 = synth_group_single_table();
g7.parent_table = "tags".to_string();
g7.join_tables.push(PkFlipJoinTable {
table: "book_tags".to_string(),
fk_to_parent_column: "tag_id".to_string(),
fk_to_parent_constraint: "book_tags_tag_id_fkey".to_string(),
fk_to_parent_deferrable: false,
fk_to_parent_initially_deferred: false,
fk_to_partner_column: None,
fk_to_partner_constraint: None,
fk_to_partner_table: None,
fk_to_partner_deferrable: false,
fk_to_partner_initially_deferred: false,
family: PkFlipFamily::Heer,
});
let plan_7 = super::lower_pk_flip_group(&g7, bucket()).expect("lower pk flip group");
write(
"pk_flip_emitter_output_section_7.sql",
whole_plan_normalised(&plan_7),
);
let mut g8 = synth_group_single_table();
g8.parent_table = "a".to_string();
g8.children.push(PkFlipChild {
table: "b".to_string(),
fk_column: "a_id".to_string(),
fk_constraint_name: "b_a_id_fkey".to_string(),
on_delete: OnDeleteSchema::Restrict,
fk_deferrable: true,
fk_initially_deferred: true,
fk_nullable: true,
fk_unique: false,
family: PkFlipFamily::Heer,
cycle_flag: true,
});
g8.cycles.push(PkFlipCycle {
peer_table: "b".to_string(),
peer_fk_column: "a_id".to_string(),
self_fk_column: "b_id".to_string(),
});
let plan_8 = super::lower_pk_flip_group(&g8, bucket()).expect("lower pk flip group");
write(
"pk_flip_emitter_output_section_8.sql",
whole_plan_normalised(&plan_8),
);
let mut g9 = synth_group_single_table();
g9.parent_table = "events".to_string();
g9.partitioned_parent = Some(super::super::diff::PkFlipPartitionedMeta {
partition: PartitionSchema::Range {
column: "ts".to_string(),
},
});
let plan_9 = super::lower_pk_flip_group(&g9, bucket()).expect("lower pk flip group");
write(
"pk_flip_emitter_output_section_9.sql",
whole_plan_normalised(&plan_9),
);
}
#[test]
fn whole_plan_byte_equality_section_3_forward() {
let plan = lowered_plan_section_3();
let actual = whole_plan_normalised(&plan);
let expected = whitespace_normalize(EMITTER_OUTPUT_SECTION_3_NORMALIZED);
assert_eq!(
actual, expected,
"whole-plan §3 forward output drifted from fixture; \
update tests/fixtures/pk_flip_emitter_output_section_3.sql or fix emitter",
);
}
const EMITTER_OUTPUT_SECTION_3_REVERSE_NORMALIZED: &str =
include_str!("fixtures/pk_flip_emitter_output_section_3_reverse.sql");
#[test]
fn whole_plan_byte_equality_section_3_reverse() {
let mut group = synth_group_single_table();
group.parent_from = PkKindSchema::HeerIdRecencyBiased;
group.parent_to = PkKindSchema::HeerId;
group.direction = PkFlipDirection::DescToAsc;
let plan = super::lower_pk_flip_group(&group, bucket()).expect("lower pk flip group");
let actual = whole_plan_normalised(&plan);
let expected = whitespace_normalize(EMITTER_OUTPUT_SECTION_3_REVERSE_NORMALIZED);
assert_eq!(
actual, expected,
"whole-plan §3 reverse output drifted from fixture; \
update tests/fixtures/pk_flip_emitter_output_section_3_reverse.sql or fix emitter",
);
}
const EMITTER_OUTPUT_SECTION_4_NORMALIZED: &str =
include_str!("fixtures/pk_flip_emitter_output_section_4.sql");
#[test]
fn whole_plan_byte_equality_section_4_parent_child() {
let mut group = synth_group_single_table();
group.parent_table = "parent".to_string();
group.children.push(PkFlipChild {
table: "c".to_string(),
fk_column: "p_id".to_string(),
fk_constraint_name: "c_p_id_fkey".to_string(),
on_delete: OnDeleteSchema::Restrict,
fk_deferrable: false,
fk_initially_deferred: false,
fk_nullable: false,
fk_unique: false,
family: PkFlipFamily::Heer,
cycle_flag: false,
});
let plan = super::lower_pk_flip_group(&group, bucket()).expect("lower pk flip group");
let actual = whole_plan_normalised(&plan);
let expected = whitespace_normalize(EMITTER_OUTPUT_SECTION_4_NORMALIZED);
assert_eq!(
actual, expected,
"whole-plan §4 output drifted from fixture; update fixture or fix emitter",
);
}
const EMITTER_OUTPUT_SECTION_6_NORMALIZED: &str =
include_str!("fixtures/pk_flip_emitter_output_section_6.sql");
#[test]
fn whole_plan_byte_equality_section_6_self_fk() {
let mut group = synth_group_single_table();
group.parent_table = "nodes".to_string();
group.self_fk = Some(PkFlipSelfFk {
fk_columns: vec!["parent_id".to_string()],
fk_constraint_names: vec!["nodes_parent_id_fkey".to_string()],
fk_deferrable: vec![false],
fk_initially_deferred: vec![false],
});
let plan = super::lower_pk_flip_group(&group, bucket()).expect("lower pk flip group");
let actual = whole_plan_normalised(&plan);
let expected = whitespace_normalize(EMITTER_OUTPUT_SECTION_6_NORMALIZED);
assert_eq!(
actual, expected,
"whole-plan §6 output drifted from fixture; update fixture or fix emitter",
);
}
const EMITTER_OUTPUT_SECTION_7_NORMALIZED: &str =
include_str!("fixtures/pk_flip_emitter_output_section_7.sql");
#[test]
fn whole_plan_byte_equality_section_7_join_table() {
let mut group = synth_group_single_table();
group.parent_table = "tags".to_string();
group.join_tables.push(PkFlipJoinTable {
table: "book_tags".to_string(),
fk_to_parent_column: "tag_id".to_string(),
fk_to_parent_constraint: "book_tags_tag_id_fkey".to_string(),
fk_to_parent_deferrable: false,
fk_to_parent_initially_deferred: false,
fk_to_partner_column: None,
fk_to_partner_constraint: None,
fk_to_partner_table: None,
fk_to_partner_deferrable: false,
fk_to_partner_initially_deferred: false,
family: PkFlipFamily::Heer,
});
let plan = super::lower_pk_flip_group(&group, bucket()).expect("lower pk flip group");
let actual = whole_plan_normalised(&plan);
let expected = whitespace_normalize(EMITTER_OUTPUT_SECTION_7_NORMALIZED);
assert_eq!(
actual, expected,
"whole-plan §7 output drifted from fixture; update fixture or fix emitter",
);
}
const EMITTER_OUTPUT_SECTION_8_NORMALIZED: &str =
include_str!("fixtures/pk_flip_emitter_output_section_8.sql");
#[test]
fn whole_plan_byte_equality_section_8_cycle() {
let mut group = synth_group_single_table();
group.parent_table = "a".to_string();
group.children.push(PkFlipChild {
table: "b".to_string(),
fk_column: "a_id".to_string(),
fk_constraint_name: "b_a_id_fkey".to_string(),
on_delete: OnDeleteSchema::Restrict,
fk_deferrable: true,
fk_initially_deferred: true,
fk_nullable: true,
fk_unique: false,
family: PkFlipFamily::Heer,
cycle_flag: true,
});
group.cycles.push(PkFlipCycle {
peer_table: "b".to_string(),
peer_fk_column: "a_id".to_string(),
self_fk_column: "b_id".to_string(),
});
let plan = super::lower_pk_flip_group(&group, bucket()).expect("lower pk flip group");
let actual = whole_plan_normalised(&plan);
let expected = whitespace_normalize(EMITTER_OUTPUT_SECTION_8_NORMALIZED);
assert_eq!(
actual, expected,
"whole-plan §8 output drifted from fixture; update fixture or fix emitter",
);
}
const EMITTER_OUTPUT_SECTION_9_NORMALIZED: &str =
include_str!("fixtures/pk_flip_emitter_output_section_9.sql");
#[test]
fn whole_plan_byte_equality_section_9_partitioned() {
let mut group = synth_group_single_table();
group.parent_table = "events".to_string();
group.partitioned_parent = Some(super::super::diff::PkFlipPartitionedMeta {
partition: PartitionSchema::Range {
column: "ts".to_string(),
},
});
let plan = super::lower_pk_flip_group(&group, bucket()).expect("lower pk flip group");
let actual = whole_plan_normalised(&plan);
let expected = whitespace_normalize(EMITTER_OUTPUT_SECTION_9_NORMALIZED);
assert_eq!(
actual, expected,
"whole-plan §9 output drifted from fixture; update fixture or fix emitter",
);
}
#[test]
fn fixture_section_3_carries_every_playbook_anchor_substring() {
let fx = whitespace_normalize(EMITTER_OUTPUT_SECTION_3_NORMALIZED);
assert!(
fx.contains("ALTER TABLE tbl ADD COLUMN id_desc bigint"),
"§3.1 ADD COLUMN missing from fixture",
);
assert!(
fx.contains("zzz_tbl_autofill_desc"),
"§3.1 autofill trigger name missing",
);
assert!(
fx.contains("heerid_to_desc(NEW.id)"),
"§3.1 trigger body must call heerid_to_desc on NEW.id",
);
assert!(
fx.contains("BEFORE INSERT OR UPDATE ON tbl"),
"§3.1 trigger must be BEFORE INSERT OR UPDATE",
);
assert!(
fx.contains("CALL heeranjid_bulk_backfill('tbl', 'id', 'id_desc', 'heer', 10000)"),
"§3.2 bulk backfill CALL missing or signature drifted",
);
assert!(
fx.contains("SELECT count(*) FROM tbl WHERE id_desc IS NULL"),
"§3.3 NULL-shadow verification missing",
);
assert!(
fx.contains("CREATE UNIQUE INDEX CONCURRENTLY idx_tbl_id_desc ON tbl (id_desc)"),
"§3.4 concurrent unique index missing",
);
assert!(
fx.contains("ADD CONSTRAINT tbl_id_desc_nn CHECK (id_desc IS NOT NULL) NOT VALID"),
"§3.5 NOT VALID CHECK missing",
);
assert!(
fx.contains("VALIDATE CONSTRAINT tbl_id_desc_nn"),
"§3.5 VALIDATE CONSTRAINT missing",
);
assert!(
fx.contains("ALTER COLUMN id_desc SET NOT NULL"),
"§3.5 SET NOT NULL missing",
);
assert!(
fx.contains("DROP CONSTRAINT tbl_id_desc_nn"),
"§3.5 DROP CONSTRAINT (NN cleanup) missing",
);
assert!(
fx.contains("ALTER TABLE tbl DROP CONSTRAINT tbl_pkey"),
"§3.6 DROP old PK missing",
);
assert!(
fx.contains("ADD CONSTRAINT tbl_pkey PRIMARY KEY USING INDEX idx_tbl_id_desc"),
"§3.6 promote shadow to PK USING INDEX missing",
);
assert!(
fx.contains("ALTER COLUMN id_desc SET DEFAULT heerid_next_desc()"),
"§3.6 set new default missing",
);
assert!(
fx.contains("ALTER TABLE tbl DROP COLUMN id"),
"§3.6 drop old column missing",
);
assert!(
fx.contains("DROP TRIGGER zzz_tbl_autofill_desc ON tbl"),
"§3.6 drop trigger missing",
);
assert!(
fx.contains("DROP FUNCTION zzz_tbl_autofill_desc() CASCADE"),
"§3.6 drop trigger function missing",
);
assert!(
fx.contains("ALTER TABLE tbl RENAME COLUMN id_desc TO id"),
"§3.6 final rename missing",
);
}
#[test]
fn fixture_section_3_reverse_applies_documented_substitution() {
let fx = whitespace_normalize(EMITTER_OUTPUT_SECTION_3_REVERSE_NORMALIZED);
assert!(
fx.contains("heerid_to_asc(NEW.id)"),
"reverse fixture must call heerid_to_asc in trigger body",
);
assert!(
fx.contains("SET DEFAULT heerid_next()"),
"reverse fixture must set DEFAULT to heerid_next() (asc generator)",
);
assert!(
!fx.contains("heerid_next_desc()"),
"reverse fixture must NOT contain heerid_next_desc() — that is the forward default",
);
assert!(fx.contains("ALTER TABLE tbl ADD COLUMN id_desc bigint"));
assert!(fx.contains("CREATE UNIQUE INDEX CONCURRENTLY idx_tbl_id_desc"));
assert!(fx.contains("ALTER TABLE tbl DROP CONSTRAINT tbl_pkey"));
}
#[test]
fn fixture_section_4_carries_every_playbook_anchor_substring() {
let fx = whitespace_normalize(EMITTER_OUTPUT_SECTION_4_NORMALIZED);
assert!(fx.contains("ALTER TABLE parent ADD COLUMN id_desc bigint"));
assert!(fx.contains("zzz_parent_autofill_desc"));
assert!(fx.contains("ALTER TABLE c ADD COLUMN p_id_desc bigint"));
assert!(fx.contains(
"ADD CONSTRAINT c_p_id_desc_fkey FOREIGN KEY (p_id_desc) REFERENCES parent(id_desc) NOT VALID"
));
assert!(
fx.contains("CALL heeranjid_bulk_backfill('c', 'p_id', 'p_id_desc', 'heer', 10000)")
);
assert!(fx.contains("VALIDATE CONSTRAINT c_p_id_desc_fkey"));
assert!(fx.contains("ALTER TABLE c DROP CONSTRAINT c_p_id_fkey"));
assert!(fx.contains("ALTER TABLE parent DROP CONSTRAINT parent_pkey"));
assert!(
fx.contains("ADD CONSTRAINT parent_pkey PRIMARY KEY USING INDEX idx_parent_id_desc")
);
assert!(fx.contains("ALTER TABLE parent RENAME COLUMN id_desc TO id"));
assert!(fx.contains("ALTER TABLE c DROP COLUMN p_id"));
assert!(fx.contains("ALTER TABLE c RENAME COLUMN p_id_desc TO p_id"));
assert!(fx.contains("ADD CONSTRAINT c_p_id_fkey FOREIGN KEY (p_id) REFERENCES parent(id)"));
}
#[test]
fn fixture_section_6_carries_every_playbook_anchor_substring() {
let fx = whitespace_normalize(EMITTER_OUTPUT_SECTION_6_NORMALIZED);
assert!(fx.contains("ALTER TABLE nodes ADD COLUMN id_desc bigint"));
assert!(fx.contains("ALTER TABLE nodes ADD COLUMN parent_id_desc bigint"));
assert!(fx.contains("zzz_nodes_autofill_desc"));
assert!(fx.contains(
"ADD CONSTRAINT nodes_parent_id_desc_fkey FOREIGN KEY (parent_id_desc) REFERENCES nodes(id_desc) NOT VALID"
));
assert!(
fx.contains("CALL heeranjid_bulk_backfill('nodes', 'id', 'id_desc', 'heer', 10000)")
);
assert!(fx.contains(
"CALL heeranjid_bulk_backfill('nodes', 'parent_id', 'parent_id_desc', 'heer', 10000)"
));
assert!(fx.contains("CREATE UNIQUE INDEX CONCURRENTLY idx_nodes_id_desc"));
assert!(fx.contains("CREATE INDEX CONCURRENTLY idx_nodes_parent_id_desc"));
assert!(fx.contains("ALTER TABLE nodes DROP CONSTRAINT nodes_parent_id_fkey"));
assert!(fx.contains("ALTER TABLE nodes DROP CONSTRAINT nodes_pkey"));
assert!(fx.contains("ADD CONSTRAINT nodes_pkey PRIMARY KEY USING INDEX idx_nodes_id_desc"));
assert!(fx.contains("ALTER TABLE nodes DROP COLUMN id"));
assert!(fx.contains("ALTER TABLE nodes DROP COLUMN parent_id"));
assert!(fx.contains("ALTER TABLE nodes RENAME COLUMN id_desc TO id"));
assert!(fx.contains("ALTER TABLE nodes RENAME COLUMN parent_id_desc TO parent_id"));
assert!(fx.contains(
"ADD CONSTRAINT nodes_parent_id_fkey FOREIGN KEY (parent_id) REFERENCES nodes(id)"
));
}
#[test]
fn fixture_section_7_carries_every_playbook_anchor_substring() {
let fx = whitespace_normalize(EMITTER_OUTPUT_SECTION_7_NORMALIZED);
assert!(fx.contains("ALTER TABLE tags ADD COLUMN id_desc bigint"));
assert!(fx.contains("zzz_tags_autofill_desc"));
assert!(fx.contains("ALTER TABLE book_tags ADD COLUMN tag_id_desc bigint"));
assert!(fx.contains("zzz_book_tags_autofill_desc"));
assert!(fx.contains(
"ADD CONSTRAINT book_tags_tag_id_desc_fkey FOREIGN KEY (tag_id_desc) REFERENCES tags(id_desc) NOT VALID"
));
assert!(
fx.contains("CALL heeranjid_bulk_backfill('tags', 'id', 'id_desc', 'heer', 10000)")
);
assert!(fx.contains(
"CALL heeranjid_bulk_backfill('book_tags', 'tag_id', 'tag_id_desc', 'heer', 10000)"
));
assert!(fx.contains("ALTER TABLE book_tags DROP CONSTRAINT book_tags_tag_id_fkey"));
assert!(fx.contains("ALTER TABLE tags DROP CONSTRAINT tags_pkey"));
assert!(fx.contains("ALTER TABLE book_tags DROP COLUMN tag_id"));
assert!(fx.contains("ALTER TABLE book_tags RENAME COLUMN tag_id_desc TO tag_id"));
assert!(fx.contains(
"ADD CONSTRAINT book_tags_tag_id_fkey FOREIGN KEY (tag_id) REFERENCES tags(id)"
));
}
#[test]
fn fixture_section_8_carries_every_playbook_anchor_substring() {
let fx = whitespace_normalize(EMITTER_OUTPUT_SECTION_8_NORMALIZED);
assert!(
fx.contains("SET CONSTRAINTS ALL DEFERRED"),
"§8 cycle cutover must defer all constraints",
);
assert!(
fx.contains("DEFERRABLE INITIALLY DEFERRED"),
"§8 cycle FK must be DEFERRABLE INITIALLY DEFERRED",
);
}
#[test]
fn fixture_section_9_carries_every_playbook_anchor_substring() {
let fx = whitespace_normalize(EMITTER_OUTPUT_SECTION_9_NORMALIZED);
assert!(fx.contains("ALTER TABLE events ADD COLUMN id_desc bigint"));
assert!(
fx.contains("ON ONLY events"),
"§9.5 parent-level UNIQUE must be ON ONLY parent",
);
assert!(
fx.contains("EACH_LEAF_TABLE"),
"§9 per-leaf placeholder marker must be present",
);
assert!(
fx.contains("ADD PRIMARY KEY (ts, id_desc)"),
"§9.7 partitioned-parent PK must use ADD PRIMARY KEY (partition_key, id_desc)",
);
assert!(
!fx.contains("PRIMARY KEY USING INDEX idx_events"),
"§9.7 partitioned-parent must NOT use USING INDEX (illegal on partitioned parent)",
);
}
fn locate_playbook_md() -> Option<std::path::PathBuf> {
let manifest = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
let workspace = manifest.parent()?;
let candidates = [
workspace.join("HeeRanjID-reference/docs/migrations/asc-to-desc.md"),
workspace
.parent()?
.join("HeeRanjID/docs/migrations/asc-to-desc.md"),
];
candidates.into_iter().find(|p| p.is_file())
}
fn extract_playbook_lines(md: &str, start_line: usize, end_line: usize) -> String {
md.lines()
.skip(start_line - 1)
.take(end_line - start_line + 1)
.collect::<Vec<_>>()
.join("\n")
+ "\n"
}
fn assert_canonical_section(
section_label: &str,
playbook: &str,
start_line: usize,
end_line: usize,
fixture_bytes: &str,
) {
let excerpt = extract_playbook_lines(playbook, start_line, end_line);
if excerpt != fixture_bytes {
panic!(
"Codex round-4 B-5r: canonical fixture for {section_label} \
drifted from playbook text (lines {start_line}..={end_line}).\n\
Either the playbook edited a load-bearing SQL block — update\n\
the matching `playbook_canonical_section_*.sql` fixture to\n\
the new excerpt — or the fixture itself drifted, in which\n\
case revert the fixture.\n\n\
---- playbook excerpt ({start_line}..={end_line}) ----\n{excerpt}\n\
---- fixture ----\n{fixture_bytes}",
);
}
}
#[test]
fn playbook_canonical_section_3_locks_against_silent_edits() {
let Some(md_path) = locate_playbook_md() else {
eprintln!(
"[skip] B-5r canonical fixture §3 — playbook asc-to-desc.md not\n\
reachable from djogi/. Set up the HeeRanjID-reference symlink at\n\
the workspace root (or the sibling HeeRanjID workspace) to\n\
enable this lock.",
);
return;
};
let md = std::fs::read_to_string(&md_path).expect("read playbook");
assert_canonical_section(
"§3 cutover",
&md,
178,
187,
include_str!("fixtures/playbook_canonical_section_3.sql"),
);
}
#[test]
fn playbook_canonical_section_4_locks_against_silent_edits() {
let Some(md_path) = locate_playbook_md() else {
eprintln!("[skip] B-5r §4 — see §3 skip note for setup");
return;
};
let md = std::fs::read_to_string(&md_path).expect("read playbook");
assert_canonical_section(
"§4 cutover",
&md,
232,
254,
include_str!("fixtures/playbook_canonical_section_4.sql"),
);
}
#[test]
fn playbook_canonical_section_6_locks_against_silent_edits() {
let Some(md_path) = locate_playbook_md() else {
eprintln!("[skip] B-5r §6 — see §3 skip note for setup");
return;
};
let md = std::fs::read_to_string(&md_path).expect("read playbook");
assert_canonical_section(
"§6 cutover",
&md,
307,
322,
include_str!("fixtures/playbook_canonical_section_6.sql"),
);
}
#[test]
fn playbook_canonical_section_8_locks_against_silent_edits() {
let Some(md_path) = locate_playbook_md() else {
eprintln!("[skip] B-5r §8 — see §3 skip note for setup");
return;
};
let md = std::fs::read_to_string(&md_path).expect("read playbook");
assert_canonical_section(
"§8 deferrable FK pair",
&md,
362,
372,
include_str!("fixtures/playbook_canonical_section_8.sql"),
);
}
#[test]
fn playbook_canonical_section_9_locks_against_silent_edits() {
let Some(md_path) = locate_playbook_md() else {
eprintln!("[skip] B-5r §9 — see §3 skip note for setup");
return;
};
let md = std::fs::read_to_string(&md_path).expect("read playbook");
assert_canonical_section(
"§9 partitioned cutover",
&md,
459,
477,
include_str!("fixtures/playbook_canonical_section_9.sql"),
);
}
}