use std::collections::{BTreeMap, BTreeSet};
use super::compose::{
date_array_helper_operation, numeric_array_helper_operation, requires_date_array_helper,
requires_numeric_array_helper, requires_tstz_array_helper, tstz_array_helper_operation,
};
use super::diff::{Classification, SchemaDelta, SchemaOperation};
use super::projection::BucketKey;
use super::schema::TableSchema;
use super::sql::{OperationSql, SqlEmitError, lower_operation};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MigrationPlan {
pub bucket: BucketKey,
pub classification: Classification,
pub segments: Vec<Segment>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Segment {
pub kind: SegmentKind,
pub statements: Vec<OperationSql>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SegmentKind {
Transactional,
NonTransactional,
MetadataOnly,
}
impl Segment {
fn new_if_non_empty(kind: SegmentKind, statements: Vec<OperationSql>) -> Option<Segment> {
if statements.is_empty() {
None
} else {
Some(Segment { kind, statements })
}
}
}
#[allow(clippy::result_large_err)]
pub fn plan_delta(delta: &SchemaDelta) -> Result<MigrationPlan, SqlEmitError> {
if matches!(delta.classification, Classification::NoOp) {
return Ok(MigrationPlan {
bucket: delta.bucket.clone(),
classification: delta.classification.clone(),
segments: Vec::new(),
});
}
let mut group_segments: Vec<Segment> = Vec::new();
for op in &delta.operations {
match op {
SchemaOperation::PkTypeFlipGroup(g) => {
group_segments.extend(
super::pk_flip::build_segments(g).map_err(|e| SqlEmitError::Diff(e.into()))?,
);
}
SchemaOperation::PkTypeFlipMultiGroup(groups) => {
group_segments.extend(
super::pk_flip::build_segments_multi(groups).map_err(SqlEmitError::Diff)?,
);
}
_ => {}
}
}
if !group_segments.is_empty() {
return Ok(MigrationPlan {
bucket: delta.bucket.clone(),
classification: delta.classification.clone(),
segments: group_segments,
});
}
let ordered = order_operations(&delta.operations);
let mut segments: Vec<Segment> = Vec::new();
let mut lowered_ops: Vec<OperationSql> = Vec::with_capacity(ordered.len());
let mut lowered_kinds: Vec<SegmentKind> = Vec::with_capacity(ordered.len());
for op in &ordered {
let kind = classify_operation(op);
let lowered = lower_operation(op)?;
lowered_ops.push(lowered);
lowered_kinds.push(kind);
}
let mut helper_ops: Vec<OperationSql> = Vec::new();
let mut helper_kinds: Vec<SegmentKind> = Vec::new();
if requires_numeric_array_helper(&lowered_ops) {
helper_ops.push(numeric_array_helper_operation());
helper_kinds.push(SegmentKind::Transactional);
}
if requires_date_array_helper(&lowered_ops) {
helper_ops.push(date_array_helper_operation());
helper_kinds.push(SegmentKind::Transactional);
}
if requires_tstz_array_helper(&lowered_ops) {
helper_ops.push(tstz_array_helper_operation());
helper_kinds.push(SegmentKind::Transactional);
}
if !helper_ops.is_empty() {
helper_ops.extend(lowered_ops);
helper_kinds.extend(lowered_kinds);
lowered_ops = helper_ops;
lowered_kinds = helper_kinds;
}
let mut current_kind: Option<SegmentKind> = None;
let mut current_stmts: Vec<OperationSql> = Vec::new();
for (kind, op) in lowered_kinds.into_iter().zip(lowered_ops) {
match current_kind {
Some(k) if k == kind => current_stmts.push(op),
_ => {
if let Some(seg) = Segment::new_if_non_empty(
current_kind.unwrap_or(SegmentKind::Transactional),
std::mem::take(&mut current_stmts),
) {
segments.push(seg);
}
current_kind = Some(kind);
current_stmts.push(op);
}
}
}
if let Some(seg) = Segment::new_if_non_empty(
current_kind.unwrap_or(SegmentKind::Transactional),
current_stmts,
) {
segments.push(seg);
}
Ok(MigrationPlan {
bucket: delta.bucket.clone(),
classification: delta.classification.clone(),
segments,
})
}
pub(crate) fn classify_operation(op: &SchemaOperation) -> SegmentKind {
match op {
SchemaOperation::RenameApp { .. } | SchemaOperation::MoveModelBetweenApps { .. } => {
SegmentKind::MetadataOnly
}
SchemaOperation::AddIndex(idx) | SchemaOperation::DropIndex(idx)
if idx.requires_out_of_transaction =>
{
SegmentKind::NonTransactional
}
_ => SegmentKind::Transactional,
}
}
fn order_operations(ops: &[SchemaOperation]) -> Vec<SchemaOperation> {
let mut tagged: Vec<(usize, usize, SchemaOperation)> = ops
.iter()
.enumerate()
.map(|(i, op)| (operation_phase(op), i, op.clone()))
.collect();
tagged.sort_by_key(|(phase, idx, _)| (*phase, *idx));
let mut head: Vec<SchemaOperation> = Vec::with_capacity(tagged.len());
let mut add_tables: Vec<TableSchema> = Vec::new();
let mut tail: Vec<SchemaOperation> = Vec::with_capacity(tagged.len());
for (phase, _, op) in tagged {
match (phase, op) {
(2, SchemaOperation::AddTable(t)) => add_tables.push(t),
(p, op) if p < 2 => head.push(op),
(_, op) => tail.push(op),
}
}
let (toposorted, follow_up_fks) = toposort_add_tables(add_tables);
let mut out =
Vec::with_capacity(head.len() + toposorted.len() + follow_up_fks.len() + tail.len());
out.extend(head);
out.extend(toposorted.into_iter().map(SchemaOperation::AddTable));
out.extend(follow_up_fks);
out.extend(tail);
out
}
fn toposort_add_tables(tables: Vec<TableSchema>) -> (Vec<TableSchema>, Vec<SchemaOperation>) {
if tables.is_empty() {
return (Vec::new(), Vec::new());
}
let mut by_name: BTreeMap<String, TableSchema> = BTreeMap::new();
for t in tables {
by_name.insert(t.table.clone(), t);
}
let in_batch: BTreeSet<String> = by_name.keys().cloned().collect();
let mut deps: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
for (name, t) in &by_name {
let mut set: BTreeSet<String> = BTreeSet::new();
for col in &t.columns {
if let Some(fk) = &col.foreign_key
&& fk.ref_table != *name
&& in_batch.contains(&fk.ref_table)
{
set.insert(fk.ref_table.clone());
}
}
deps.insert(name.clone(), set);
}
let mut reverse: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
for name in by_name.keys() {
reverse.insert(name.clone(), BTreeSet::new());
}
for (dependent, deps_of) in &deps {
for dependency in deps_of {
reverse
.get_mut(dependency)
.expect("reverse entry exists for every batch table")
.insert(dependent.clone());
}
}
let mut ready: BTreeSet<String> = deps
.iter()
.filter_map(|(n, ds)| if ds.is_empty() { Some(n.clone()) } else { None })
.collect();
let mut ordered: Vec<TableSchema> = Vec::with_capacity(by_name.len());
while let Some(next) = ready.iter().next().cloned() {
ready.remove(&next);
let t = by_name
.remove(&next)
.expect("ready entries are always still in by_name");
ordered.push(t);
let dependents: Vec<String> = reverse
.get(&next)
.map(|s| s.iter().cloned().collect())
.unwrap_or_default();
for dependent in dependents {
let Some(d) = deps.get_mut(&dependent) else {
continue;
};
d.remove(&next);
if d.is_empty() {
ready.insert(dependent);
}
}
}
if by_name.is_empty() {
return (ordered, Vec::new());
}
let cycle_members: BTreeSet<String> = by_name.keys().cloned().collect();
let mut follow_up_fks: Vec<SchemaOperation> = Vec::new();
for (name, mut t) in std::mem::take(&mut by_name) {
let owning_table = t.table.clone();
for col in t.columns.iter_mut() {
let strip = col
.foreign_key
.as_ref()
.map(|fk| cycle_members.contains(&fk.ref_table))
.unwrap_or(false);
if strip {
let fk = col
.foreign_key
.take()
.expect("strip implies foreign_key.is_some()");
follow_up_fks.push(SchemaOperation::AddForeignKey {
table: owning_table.clone(),
column: col.name.clone(),
fk,
});
}
}
ordered.push(t);
let _ = name;
}
(ordered, follow_up_fks)
}
fn operation_phase(op: &SchemaOperation) -> usize {
match op {
SchemaOperation::AddEnum(_) => 0,
SchemaOperation::RenameTable { .. } => 1,
SchemaOperation::AddTable(_) => 2,
SchemaOperation::AddColumn { .. }
| SchemaOperation::RenameColumn { .. }
| SchemaOperation::AlterColumn { .. }
| SchemaOperation::AddForeignKey { .. }
| SchemaOperation::SetTableComment { .. }
| SchemaOperation::SetStorageParams { .. }
| SchemaOperation::SetTablespace { .. } => 3,
SchemaOperation::AddEnumVariant { .. } => 4,
SchemaOperation::AddIndex(_) => 5,
SchemaOperation::AddExclusionConstraint { .. } => 5,
SchemaOperation::DropIndex(_) => 6,
SchemaOperation::DropExclusionConstraint { .. } => 6,
SchemaOperation::DropForeignKey { .. } => 7,
SchemaOperation::DropColumn { .. } => 8,
SchemaOperation::DropTable(_) => 9,
SchemaOperation::DropEnum(_) => 10,
SchemaOperation::RenameApp { .. } | SchemaOperation::MoveModelBetweenApps { .. } => 11,
SchemaOperation::PkTypeFlip { .. }
| SchemaOperation::PkTypeFlipGroup(_)
| SchemaOperation::PkTypeFlipMultiGroup(_)
| SchemaOperation::Unsupported { .. } => 12,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::migrate::diff::{
ColumnChange, PkFlipJoinTableOption, PkFlipPartitionedMeta, PkTypeFlipGroup, SchemaDelta,
};
use crate::migrate::projection::BucketKey;
use crate::migrate::schema::{
ColumnSchema, EnumSchema, ForeignKeySchema, IndexColumnSchema, IndexKindSchema,
IndexNullsOrderSchema, IndexOrderSchema, IndexSchema, IndexTargetSchema, IndexTypeSchema,
OnDeleteSchema, PkKindSchema, PrimaryKeySchema, TableSchema,
};
fn bucket() -> BucketKey {
BucketKey {
database: "main".to_string(),
app: "".to_string(),
}
}
fn col(name: &str, ty: &str, nullable: bool) -> ColumnSchema {
ColumnSchema {
check: None,
comment: None,
default_sql: None,
foreign_key: None,
generated: None,
identity: None,
index_type: None,
indexed: false,
max_length: None,
name: name.to_string(),
nullable,
on_delete: None,
outbox_exclude: false,
rationale: None,
relation_kind: None,
renamed_from: None,
sequence_within: None,
sql_type: ty.to_string(),
unique: false,
type_change_using: None,
}
}
fn id_column_heerid() -> ColumnSchema {
ColumnSchema {
default_sql: Some("heerid_next()".to_string()),
..col("id", "BIGINT", false)
}
}
fn numeric_array_column(name: &str) -> ColumnSchema {
ColumnSchema {
check: Some("djogi.__djogi_numeric_array_is_rust_decimal_v1(\"amounts\")".to_string()),
..col(name, "NUMERIC[]", true)
}
}
fn date_array_column(name: &str) -> ColumnSchema {
ColumnSchema {
check: Some(format!("djogi.__djogi_date_array_is_finite_v1(\"{name}\")")),
..col(name, "DATE[]", true)
}
}
fn tstz_array_column(name: &str) -> ColumnSchema {
ColumnSchema {
check: Some(format!("djogi.__djogi_tstz_array_is_finite_v1(\"{name}\")")),
..col(name, "TIMESTAMPTZ[]", true)
}
}
fn synth_table(name: &str) -> TableSchema {
TableSchema {
app: None,
columns: vec![id_column_heerid(), col("name", "TEXT", true)],
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 idx(name: &str, table: &str, cols: &[&str]) -> IndexSchema {
IndexSchema {
extension_dependency: None,
include: Vec::new(),
index_type: IndexTypeSchema::BTree,
kind: IndexKindSchema::NonUnique,
name: name.to_string(),
nulls_not_distinct: false,
predicate: None,
requires_out_of_transaction: false,
table: table.to_string(),
target: IndexTargetSchema::Columns(
cols.iter()
.map(|c| IndexColumnSchema {
name: (*c).to_string(),
nulls: IndexNullsOrderSchema::Default,
opclass: None,
order: IndexOrderSchema::Asc,
})
.collect(),
),
}
}
#[test]
fn noop_delta_yields_empty_plan() {
let delta = SchemaDelta {
bucket: bucket(),
operations: Vec::new(),
classification: Classification::NoOp,
};
let plan = plan_delta(&delta).expect("ok");
assert!(plan.segments.is_empty());
assert_eq!(plan.bucket, bucket());
assert_eq!(plan.classification, Classification::NoOp);
}
#[test]
fn single_add_table_lands_in_one_transactional_segment() {
let delta = SchemaDelta {
bucket: bucket(),
operations: vec![SchemaOperation::AddTable(synth_table("users"))],
classification: Classification::Additive,
};
let plan = plan_delta(&delta).expect("ok");
assert_eq!(plan.segments.len(), 1);
assert_eq!(plan.segments[0].kind, SegmentKind::Transactional);
assert_eq!(plan.segments[0].statements.len(), 1);
}
#[test]
fn numeric_array_check_triggers_helper_preload_in_plan() {
let mut table = synth_table("widgets");
table.columns.push(numeric_array_column("amounts"));
let delta = SchemaDelta {
bucket: bucket(),
operations: vec![SchemaOperation::AddTable(table)],
classification: Classification::Additive,
};
let plan = plan_delta(&delta).expect("ok");
assert_eq!(plan.segments.len(), 1);
assert_eq!(plan.segments[0].kind, SegmentKind::Transactional);
let labels: Vec<&str> = plan
.segments
.iter()
.flat_map(|s| s.statements.iter())
.map(|s| s.label.as_str())
.collect();
assert!(
labels.len() >= 2,
"expected helper + table statements; labels: {labels:?}"
);
assert_eq!(labels[0], "Ensure djogi numeric-array helper");
assert!(
plan.segments[0].statements[0]
.up
.contains("CREATE SCHEMA IF NOT EXISTS djogi;"),
"helper SQL must be prepended so execution creates djogi function",
);
assert!(
plan.segments[0].statements[1]
.up
.contains("CONSTRAINT \"widgets_amounts_check\""),
"table DDL should keep the generated NUMERIC[] constraint"
);
}
#[test]
fn date_and_tstz_array_helpers_inject_before_table_in_compose_order() {
let mut table = synth_table("events");
table.columns.push(date_array_column("blackout_dates"));
table.columns.push(tstz_array_column("scheduled_slots"));
let delta = SchemaDelta {
bucket: bucket(),
operations: vec![SchemaOperation::AddTable(table)],
classification: Classification::Additive,
};
let plan = plan_delta(&delta).expect("ok");
assert_eq!(plan.segments.len(), 1);
assert_eq!(plan.segments[0].kind, SegmentKind::Transactional);
let labels: Vec<&str> = plan.segments[0]
.statements
.iter()
.map(|s| s.label.as_str())
.collect();
assert!(
labels.len() >= 3,
"expected date helper + tstz helper + table DDL; labels: {labels:?}"
);
let date_pos = labels
.iter()
.position(|l| *l == "Ensure djogi date-array finite-element helper")
.expect("date-array helper not found in plan labels");
let tstz_pos = labels
.iter()
.position(|l| *l == "Ensure djogi timestamptz-array finite-element helper")
.expect("tstz-array helper not found in plan labels");
let table_pos = labels
.iter()
.position(|l| l.starts_with("AddTable"))
.expect("AddTable statement not found in plan labels");
assert!(
date_pos < table_pos,
"date-array helper must precede table DDL (compose order); labels: {labels:?}"
);
assert!(
tstz_pos < table_pos,
"tstz-array helper must precede table DDL (compose order); labels: {labels:?}"
);
assert!(
date_pos < tstz_pos,
"date-array helper must precede tstz-array helper \
(matches compose_up_text prelude order); labels: {labels:?}"
);
}
#[test]
fn concurrent_index_lands_in_non_transactional_segment() {
let mut i = idx("users_email_idx", "users", &["email"]);
i.requires_out_of_transaction = true;
let delta = SchemaDelta {
bucket: bucket(),
operations: vec![SchemaOperation::AddIndex(i)],
classification: Classification::Additive,
};
let plan = plan_delta(&delta).expect("ok");
assert_eq!(plan.segments.len(), 1);
assert_eq!(plan.segments[0].kind, SegmentKind::NonTransactional);
}
#[test]
fn mixed_transactional_and_non_transactional_split_into_two_segments() {
let mut concurrent_idx = idx("users_email_idx", "users", &["email"]);
concurrent_idx.requires_out_of_transaction = true;
let plain_idx = idx("users_name_idx", "users", &["name"]);
let delta = SchemaDelta {
bucket: bucket(),
operations: vec![
SchemaOperation::AddTable(synth_table("users")),
SchemaOperation::AddIndex(plain_idx),
SchemaOperation::AddIndex(concurrent_idx),
],
classification: Classification::Additive,
};
let plan = plan_delta(&delta).expect("ok");
assert_eq!(plan.segments.len(), 2);
assert_eq!(plan.segments[0].kind, SegmentKind::Transactional);
assert!(plan.segments[0].statements.len() >= 2); assert_eq!(plan.segments[1].kind, SegmentKind::NonTransactional);
}
#[test]
fn drop_index_concurrently_lands_in_non_transactional_segment() {
let mut i = idx("users_email_idx", "users", &["email"]);
i.requires_out_of_transaction = true;
let delta = SchemaDelta {
bucket: bucket(),
operations: vec![SchemaOperation::DropIndex(i)],
classification: Classification::Destructive,
};
let plan = plan_delta(&delta).expect("ok");
assert_eq!(plan.segments.len(), 1);
assert_eq!(plan.segments[0].kind, SegmentKind::NonTransactional);
}
#[test]
fn rename_app_lands_in_metadata_only_segment() {
let delta = SchemaDelta {
bucket: bucket(),
operations: vec![SchemaOperation::RenameApp {
from: "old".to_string(),
to: "new".to_string(),
}],
classification: Classification::Reversible,
};
let plan = plan_delta(&delta).expect("ok");
assert_eq!(plan.segments.len(), 1);
assert_eq!(plan.segments[0].kind, SegmentKind::MetadataOnly);
assert!(plan.segments[0].statements[0].up.contains("METADATA-ONLY"));
}
#[test]
fn move_model_between_apps_lands_in_metadata_only_segment() {
let delta = SchemaDelta {
bucket: bucket(),
operations: vec![SchemaOperation::MoveModelBetweenApps {
model: "users".to_string(),
from_app: "old".to_string(),
to_app: "new".to_string(),
}],
classification: Classification::Reversible,
};
let plan = plan_delta(&delta).expect("ok");
assert_eq!(plan.segments.len(), 1);
assert_eq!(plan.segments[0].kind, SegmentKind::MetadataOnly);
}
#[test]
fn add_enum_runs_before_add_table() {
let delta = SchemaDelta {
bucket: bucket(),
operations: vec![
SchemaOperation::AddTable(synth_table("users")),
SchemaOperation::AddEnum(EnumSchema {
name: "status".to_string(),
variants: vec!["active".to_string()],
}),
],
classification: Classification::Additive,
};
let plan = plan_delta(&delta).expect("ok");
assert_eq!(plan.segments.len(), 1);
let labels: Vec<_> = plan.segments[0]
.statements
.iter()
.map(|s| s.label.as_str())
.collect();
let enum_pos = labels
.iter()
.position(|l| l.starts_with("AddEnum"))
.unwrap();
let table_pos = labels
.iter()
.position(|l| l.starts_with("AddTable"))
.unwrap();
assert!(enum_pos < table_pos, "labels: {labels:?}");
}
#[test]
fn drop_table_runs_before_drop_enum() {
let delta = SchemaDelta {
bucket: bucket(),
operations: vec![
SchemaOperation::DropEnum("status".to_string()),
SchemaOperation::DropTable("users".to_string()),
],
classification: Classification::Destructive,
};
let plan = plan_delta(&delta).expect("ok");
let labels: Vec<_> = plan
.segments
.iter()
.flat_map(|s| s.statements.iter())
.map(|s| s.label.as_str())
.collect();
let table_pos = labels
.iter()
.position(|l| l.starts_with("DropTable"))
.unwrap();
let enum_pos = labels
.iter()
.position(|l| l.starts_with("DropEnum"))
.unwrap();
assert!(table_pos < enum_pos, "labels: {labels:?}");
}
#[test]
fn rename_table_runs_before_alter_column_on_renamed_table() {
let delta = SchemaDelta {
bucket: bucket(),
operations: vec![
SchemaOperation::AlterColumn {
table: "gadgets".to_string(),
column: "name".to_string(),
change: ColumnChange::SetNullable(false),
},
SchemaOperation::RenameTable {
from: "widgets".to_string(),
to: "gadgets".to_string(),
},
],
classification: Classification::Reversible,
};
let plan = plan_delta(&delta).expect("ok");
let labels: Vec<_> = plan
.segments
.iter()
.flat_map(|s| s.statements.iter())
.map(|s| s.label.as_str())
.collect();
let rename_pos = labels
.iter()
.position(|l| l.starts_with("RenameTable"))
.unwrap();
let alter_pos = labels
.iter()
.position(|l| l.starts_with("AlterColumn"))
.unwrap();
assert!(rename_pos < alter_pos, "labels: {labels:?}");
}
#[test]
fn drop_foreign_key_runs_before_drop_column() {
let delta = SchemaDelta {
bucket: bucket(),
operations: vec![
SchemaOperation::DropColumn {
table: "posts".to_string(),
column: "author_id".to_string(),
},
SchemaOperation::DropForeignKey {
table: "posts".to_string(),
column: "author_id".to_string(),
fk: ForeignKeySchema {
deferrable: false,
initially_deferred: false,
on_delete: OnDeleteSchema::Restrict,
ref_column: "id".to_string(),
ref_table: "users".to_string(),
},
},
],
classification: Classification::Destructive,
};
let plan = plan_delta(&delta).expect("ok");
let labels: Vec<_> = plan
.segments
.iter()
.flat_map(|s| s.statements.iter())
.map(|s| s.label.as_str())
.collect();
let fk_pos = labels
.iter()
.position(|l| l.starts_with("DropForeignKey"))
.unwrap();
let col_pos = labels
.iter()
.position(|l| l.starts_with("DropColumn"))
.unwrap();
assert!(fk_pos < col_pos, "labels: {labels:?}");
}
#[test]
fn pk_type_flip_in_delta_errors_during_planning() {
let delta = SchemaDelta {
bucket: bucket(),
operations: vec![SchemaOperation::PkTypeFlip {
table: "users".to_string(),
from: PkKindSchema::HeerId,
to: PkKindSchema::HeerIdRecencyBiased,
}],
classification: Classification::PkTypeFlip {
co_destructive: false,
co_lossy: false,
},
};
let err = plan_delta(&delta).expect_err("must error");
assert!(matches!(err, SqlEmitError::PkTypeFlipMustRouteToT9 { .. }));
}
#[test]
fn unsupported_in_delta_errors_during_planning() {
let delta = SchemaDelta {
bucket: bucket(),
operations: vec![SchemaOperation::Unsupported {
reason: "partition method change".to_string(),
}],
classification: Classification::Unsupported {
reason: "partition method change".to_string(),
},
};
let err = plan_delta(&delta).expect_err("must error");
assert!(matches!(err, SqlEmitError::Unsupported { .. }));
}
#[test]
fn partitioned_multi_parent_cluster_errors_during_planning() {
let mut left = PkTypeFlipGroup {
parent_table: "left_events".to_string(),
parent_from: PkKindSchema::HeerId,
parent_to: PkKindSchema::HeerIdRecencyBiased,
direction: crate::migrate::diff::PkFlipDirection::AscToDesc,
children: Vec::new(),
self_fk: None,
join_tables: Vec::new(),
cycles: Vec::new(),
partitioned_parent: Some(PkFlipPartitionedMeta {
partition: crate::migrate::schema::PartitionSchema::Range {
column: "ts".to_string(),
},
}),
co_destructive: false,
co_lossy: false,
join_table_option: PkFlipJoinTableOption::OptionA,
};
let right = PkTypeFlipGroup {
parent_table: "right_tags".to_string(),
parent_from: PkKindSchema::HeerId,
parent_to: PkKindSchema::HeerIdRecencyBiased,
direction: crate::migrate::diff::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: PkFlipJoinTableOption::OptionA,
};
let delta = SchemaDelta {
bucket: bucket(),
operations: vec![SchemaOperation::PkTypeFlipMultiGroup(vec![
left.clone(),
right.clone(),
])],
classification: Classification::PkTypeFlip {
co_destructive: false,
co_lossy: false,
},
};
let err = plan_delta(&delta).expect_err("must reject partitioned multi-parent cluster");
match err {
SqlEmitError::Diff(
crate::migrate::diff::DiffError::PartitionedMultiParentClusterUnsupported {
partitioned_parents,
cross_flipping_partners,
},
) => {
assert_eq!(partitioned_parents, vec!["left_events".to_string()]);
assert_eq!(
cross_flipping_partners,
vec!["left_events".to_string(), "right_tags".to_string()]
);
}
other => panic!("unexpected error: {other:?}"),
}
left.partitioned_parent = None;
let ok_delta = SchemaDelta {
bucket: bucket(),
operations: vec![SchemaOperation::PkTypeFlipMultiGroup(vec![left, right])],
classification: Classification::PkTypeFlip {
co_destructive: false,
co_lossy: false,
},
};
let plan = plan_delta(&ok_delta).expect("non-partitioned multi-group should still lower");
assert!(!plan.segments.is_empty());
}
#[test]
fn same_delta_plans_byte_identically() {
let mut concurrent_idx = idx("users_email_idx", "users", &["email"]);
concurrent_idx.requires_out_of_transaction = true;
let delta = SchemaDelta {
bucket: bucket(),
operations: vec![
SchemaOperation::AddTable(synth_table("users")),
SchemaOperation::AddIndex(concurrent_idx),
],
classification: Classification::Additive,
};
let a = plan_delta(&delta).unwrap();
let b = plan_delta(&delta).unwrap();
assert_eq!(a, b);
}
#[test]
fn adjacent_same_kind_operations_coalesce() {
let delta = SchemaDelta {
bucket: bucket(),
operations: vec![
SchemaOperation::AddTable(synth_table("users")),
SchemaOperation::AddTable(synth_table("posts")),
SchemaOperation::AddTable(synth_table("comments")),
],
classification: Classification::Additive,
};
let plan = plan_delta(&delta).expect("ok");
assert_eq!(plan.segments.len(), 1);
assert_eq!(plan.segments[0].statements.len(), 3);
}
#[test]
fn alternating_kinds_produce_alternating_segments() {
let mut c1 = idx("a_idx", "a", &["x"]);
c1.requires_out_of_transaction = true;
let mut c2 = idx("b_idx", "b", &["y"]);
c2.requires_out_of_transaction = true;
let delta = SchemaDelta {
bucket: bucket(),
operations: vec![
SchemaOperation::AddIndex(c1),
SchemaOperation::AddIndex(c2),
SchemaOperation::RenameApp {
from: "old".to_string(),
to: "new".to_string(),
},
],
classification: Classification::Reversible,
};
let plan = plan_delta(&delta).expect("ok");
assert_eq!(plan.segments.len(), 2);
assert_eq!(plan.segments[0].kind, SegmentKind::NonTransactional);
assert_eq!(plan.segments[0].statements.len(), 2);
assert_eq!(plan.segments[1].kind, SegmentKind::MetadataOnly);
}
fn fk_col(name: &str, target: &str) -> 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: false,
max_length: None,
name: name.to_string(),
nullable: false,
on_delete: Some(OnDeleteSchema::Restrict),
outbox_exclude: false,
rationale: None,
relation_kind: Some(crate::migrate::schema::RelationKindSchema::ForeignKey),
renamed_from: None,
sequence_within: None,
sql_type: "BIGINT".to_string(),
unique: false,
type_change_using: None,
}
}
fn table_with_fk(name: &str, fk_to: &[(&str, &str)]) -> TableSchema {
let mut cols = vec![id_column_heerid()];
for (col_name, target) in fk_to {
cols.push(fk_col(col_name, target));
}
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: 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 add_table_pos(plan: &MigrationPlan, name: &str) -> Option<usize> {
plan.segments
.iter()
.flat_map(|s| s.statements.iter())
.position(|s| s.label == format!("AddTable {name}"))
}
#[test]
fn fk_chain_two_tables_orders_target_before_dependent() {
let delta = SchemaDelta {
bucket: bucket(),
operations: vec![
SchemaOperation::AddTable(table_with_fk("accounts", &[("user_id", "users")])),
SchemaOperation::AddTable(synth_table("users")),
],
classification: Classification::Additive,
};
let plan = plan_delta(&delta).expect("plan");
let users_pos = add_table_pos(&plan, "users").expect("users emitted");
let accounts_pos = add_table_pos(&plan, "accounts").expect("accounts emitted");
assert!(
users_pos < accounts_pos,
"users must precede accounts; users={users_pos}, accounts={accounts_pos}"
);
}
#[test]
fn fk_chain_three_tables_orders_deepest_target_first() {
let delta = SchemaDelta {
bucket: bucket(),
operations: vec![
SchemaOperation::AddTable(table_with_fk("c", &[("b_id", "b")])),
SchemaOperation::AddTable(table_with_fk("b", &[("a_id", "a")])),
SchemaOperation::AddTable(synth_table("a")),
],
classification: Classification::Additive,
};
let plan = plan_delta(&delta).expect("plan");
let a = add_table_pos(&plan, "a").expect("a");
let b = add_table_pos(&plan, "b").expect("b");
let c = add_table_pos(&plan, "c").expect("c");
assert!(
a < b && b < c,
"expected a < b < c; got a={a}, b={b}, c={c}"
);
}
#[test]
fn fk_cycle_breaks_inline_fks_and_emits_follow_up_add_foreign_key() {
let delta = SchemaDelta {
bucket: bucket(),
operations: vec![
SchemaOperation::AddTable(table_with_fk("a", &[("b_id", "b")])),
SchemaOperation::AddTable(table_with_fk("b", &[("a_id", "a")])),
],
classification: Classification::Additive,
};
let plan = plan_delta(&delta).expect("plan");
let labels: Vec<_> = plan
.segments
.iter()
.flat_map(|s| s.statements.iter())
.map(|s| s.label.as_str())
.collect();
let a_pos = labels
.iter()
.position(|l| *l == "AddTable a")
.expect("AddTable a");
let b_pos = labels
.iter()
.position(|l| *l == "AddTable b")
.expect("AddTable b");
assert!(a_pos < b_pos, "alphabetical: a before b");
let fk_a_pos = labels
.iter()
.position(|l| *l == "AddForeignKey a.b_id")
.expect("AddForeignKey a.b_id");
let fk_b_pos = labels
.iter()
.position(|l| *l == "AddForeignKey b.a_id")
.expect("AddForeignKey b.a_id");
assert!(b_pos < fk_a_pos, "AddTable b before AddForeignKey a.b_id");
assert!(b_pos < fk_b_pos, "AddTable b before AddForeignKey b.a_id");
let create_a = plan
.segments
.iter()
.flat_map(|s| s.statements.iter())
.find(|s| s.label == "AddTable a")
.expect("AddTable a statement");
assert!(
!create_a.up.contains("REFERENCES \"b\""),
"cycle-breaking must strip inline FK; got: {}",
create_a.up
);
}
#[test]
fn independent_tables_keep_alphabetical_order_for_determinism() {
let delta = SchemaDelta {
bucket: bucket(),
operations: vec![
SchemaOperation::AddTable(synth_table("c")),
SchemaOperation::AddTable(synth_table("a")),
SchemaOperation::AddTable(synth_table("b")),
],
classification: Classification::Additive,
};
let plan = plan_delta(&delta).expect("plan");
let a = add_table_pos(&plan, "a").expect("a");
let b = add_table_pos(&plan, "b").expect("b");
let c = add_table_pos(&plan, "c").expect("c");
assert!(
a < b && b < c,
"alphabetical determinism: a < b < c; got a={a}, b={b}, c={c}"
);
}
#[test]
fn self_referencing_table_does_not_trigger_cycle_break() {
let delta = SchemaDelta {
bucket: bucket(),
operations: vec![SchemaOperation::AddTable(table_with_fk(
"tree",
&[("parent_id", "tree")],
))],
classification: Classification::Additive,
};
let plan = plan_delta(&delta).expect("plan");
let stmts: Vec<&str> = plan
.segments
.iter()
.flat_map(|s| s.statements.iter())
.map(|s| s.label.as_str())
.collect();
assert_eq!(stmts, vec!["AddTable tree"]);
let stmt = &plan.segments[0].statements[0];
assert!(
stmt.up.contains("REFERENCES \"tree\""),
"self-FK must remain inline; got: {}",
stmt.up
);
}
#[test]
fn external_fk_target_does_not_constrain_batch_ordering() {
let delta = SchemaDelta {
bucket: bucket(),
operations: vec![SchemaOperation::AddTable(table_with_fk(
"widgets",
&[("owner_id", "external_users")],
))],
classification: Classification::Additive,
};
let plan = plan_delta(&delta).expect("plan");
let labels: Vec<&str> = plan
.segments
.iter()
.flat_map(|s| s.statements.iter())
.map(|s| s.label.as_str())
.collect();
assert_eq!(labels, vec!["AddTable widgets"]);
}
#[test]
fn rename_table_runs_before_add_table_referencing_post_rename_name() {
let delta = SchemaDelta {
bucket: bucket(),
operations: vec![
SchemaOperation::AddTable(table_with_fk("comments", &[("user_id", "members")])),
SchemaOperation::RenameTable {
from: "users".to_string(),
to: "members".to_string(),
},
],
classification: Classification::Reversible,
};
let plan = plan_delta(&delta).expect("plan");
let labels: Vec<&str> = plan
.segments
.iter()
.flat_map(|s| s.statements.iter())
.map(|s| s.label.as_str())
.collect();
let rename_pos = labels
.iter()
.position(|l| l.starts_with("RenameTable"))
.expect("RenameTable in plan");
let add_pos = labels
.iter()
.position(|l| *l == "AddTable comments")
.expect("AddTable comments in plan");
assert!(
rename_pos < add_pos,
"RenameTable must precede AddTable; labels: {labels:?}"
);
let create_stmt = plan
.segments
.iter()
.flat_map(|s| s.statements.iter())
.find(|s| s.label == "AddTable comments")
.expect("AddTable comments stmt");
assert!(
create_stmt.up.contains("REFERENCES \"members\" (\"id\")"),
"inline FK must point at post-rename target; got: {}",
create_stmt.up
);
let up_stream: String = plan
.segments
.iter()
.flat_map(|s| s.statements.iter())
.map(|s| s.up.as_str())
.collect::<Vec<_>>()
.join("\n");
let rename_sql_pos = up_stream
.find("RENAME TO \"members\"")
.expect("RENAME TO members in up stream");
let create_sql_pos = up_stream
.find("CREATE TABLE \"comments\"")
.expect("CREATE TABLE comments in up stream");
assert!(
rename_sql_pos < create_sql_pos,
"ALTER TABLE ... RENAME TO must precede CREATE TABLE in up stream; \
rename_sql_pos={rename_sql_pos}, create_sql_pos={create_sql_pos}, \
stream:\n{up_stream}"
);
}
#[test]
fn rename_table_runs_before_add_table_alphabetical_determinism() {
let delta = SchemaDelta {
bucket: bucket(),
operations: vec![
SchemaOperation::RenameTable {
from: "alpha_old".to_string(),
to: "alpha".to_string(),
},
SchemaOperation::AddTable(synth_table("apples")),
SchemaOperation::RenameTable {
from: "beta_old".to_string(),
to: "beta".to_string(),
},
SchemaOperation::AddTable(synth_table("bananas")),
],
classification: Classification::Reversible,
};
let plan = plan_delta(&delta).expect("plan");
let labels: Vec<&str> = plan
.segments
.iter()
.flat_map(|s| s.statements.iter())
.map(|s| s.label.as_str())
.collect();
let rename_alpha = labels
.iter()
.position(|l| l.contains("alpha_old"))
.expect("rename alpha");
let rename_beta = labels
.iter()
.position(|l| l.contains("beta_old"))
.expect("rename beta");
let add_apples = labels
.iter()
.position(|l| *l == "AddTable apples")
.expect("add apples");
let add_bananas = labels
.iter()
.position(|l| *l == "AddTable bananas")
.expect("add bananas");
assert!(
rename_alpha < rename_beta,
"renames keep alphabetical order; labels: {labels:?}"
);
assert!(
rename_beta < add_apples,
"all renames precede all adds; labels: {labels:?}"
);
assert!(
add_apples < add_bananas,
"adds keep alphabetical order; labels: {labels:?}"
);
let up_stream: String = plan
.segments
.iter()
.flat_map(|s| s.statements.iter())
.map(|s| s.up.as_str())
.collect::<Vec<_>>()
.join("\n");
let rename_alpha_sql = up_stream
.find("RENAME TO \"alpha\"")
.expect("RENAME TO alpha in up stream");
let rename_beta_sql = up_stream
.find("RENAME TO \"beta\"")
.expect("RENAME TO beta in up stream");
let create_apples_sql = up_stream
.find("CREATE TABLE \"apples\"")
.expect("CREATE TABLE apples in up stream");
let create_bananas_sql = up_stream
.find("CREATE TABLE \"bananas\"")
.expect("CREATE TABLE bananas in up stream");
assert!(
rename_alpha_sql < rename_beta_sql,
"RENAME alpha must precede RENAME beta in up stream"
);
assert!(
rename_beta_sql < create_apples_sql,
"all RENAME TO must precede all CREATE TABLE in up stream"
);
assert!(
create_apples_sql < create_bananas_sql,
"CREATE apples must precede CREATE bananas in up stream"
);
}
}