use crate::descriptor::DefaultVolatility;
use crate::live_migrate::LoggingProfile;
use crate::migrate::diff::{ColumnChange, SchemaOperation};
use crate::migrate::pg_volatility::{Volatility, classify_default_expression};
use crate::migrate::schema::{
ColumnSchema, IndexKindSchema, IndexSchema, IndexTargetSchema, OnlineSafetyClassification,
};
use std::collections::BTreeMap;
#[derive(Debug, Clone)]
pub struct ClassifyContext<'a> {
pub estimated_rows: Option<u64>,
pub validation_threshold_rows: u64,
pub multi_fk_threshold: u32,
pub logging_profile: LoggingProfile,
pub target_database: TargetDatabase,
pub inbound_fk_counts: &'a BTreeMap<String, u32>,
pub default_volatility_overrides: &'a BTreeMap<(String, String), DefaultVolatility>,
}
impl<'a> ClassifyContext<'a> {
pub fn application_default(
inbound_fk_counts: &'a BTreeMap<String, u32>,
default_volatility_overrides: &'a BTreeMap<(String, String), DefaultVolatility>,
) -> Self {
Self {
estimated_rows: None,
validation_threshold_rows: 100_000,
multi_fk_threshold: 4,
logging_profile: LoggingProfile::Balanced,
target_database: TargetDatabase::Application,
inbound_fk_counts,
default_volatility_overrides,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum TargetDatabase {
Application,
CrudLog,
EventLog,
}
pub fn classify_operation(
op: &SchemaOperation,
ctx: &ClassifyContext<'_>,
) -> OnlineSafetyClassification {
if is_pk_type_flip_operation(op) {
return OnlineSafetyClassification::OfflineOnly;
}
if !classifier_applies(ctx) {
return OnlineSafetyClassification::OnlineSafe;
}
match op {
SchemaOperation::AddColumn { table, column } => classify_add_column(table, column, ctx),
SchemaOperation::DropColumn { .. } => {
OnlineSafetyClassification::FastLockDestructiveGuarded
}
SchemaOperation::RenameColumn { .. } => OnlineSafetyClassification::OnlineSafe,
SchemaOperation::AlterColumn { change, .. } => classify_column_change(change, ctx),
SchemaOperation::AddForeignKey { .. } => classify_fk_addition(ctx),
SchemaOperation::DropForeignKey { .. } => OnlineSafetyClassification::OnlineSafe,
SchemaOperation::AddExclusionConstraint { .. } => match ctx.estimated_rows {
Some(0) => OnlineSafetyClassification::OnlineSafe,
_ => OnlineSafetyClassification::OfflineOnly,
},
SchemaOperation::DropExclusionConstraint { .. } => OnlineSafetyClassification::OnlineSafe,
SchemaOperation::AddIndex(index) => classify_index_addition(index, ctx),
SchemaOperation::DropIndex(_) => OnlineSafetyClassification::OnlineSafe,
SchemaOperation::AddTable(_) => OnlineSafetyClassification::OnlineSafe,
SchemaOperation::DropTable(table) => classify_drop_table(table, ctx),
SchemaOperation::RenameTable { .. } => OnlineSafetyClassification::OnlineSafe,
SchemaOperation::AddEnum(_) | SchemaOperation::AddEnumVariant { .. } => {
OnlineSafetyClassification::OnlineSafe
}
SchemaOperation::DropEnum(_) => OnlineSafetyClassification::OfflineOnly,
SchemaOperation::RenameApp { .. } | SchemaOperation::MoveModelBetweenApps { .. } => {
OnlineSafetyClassification::OnlineSafe
}
SchemaOperation::SetTableComment { .. } => OnlineSafetyClassification::OnlineSafe,
SchemaOperation::SetStorageParams { .. } => OnlineSafetyClassification::OnlineSafe,
SchemaOperation::SetTablespace { .. } => OnlineSafetyClassification::OfflineOnly,
SchemaOperation::PkTypeFlip { .. }
| SchemaOperation::PkTypeFlipGroup(_)
| SchemaOperation::PkTypeFlipMultiGroup(_) => OnlineSafetyClassification::OfflineOnly,
SchemaOperation::Unsupported { .. } => OnlineSafetyClassification::OfflineOnly,
}
}
pub fn classify_delta(
ops: &[SchemaOperation],
ctx: &ClassifyContext<'_>,
) -> Vec<(SchemaOperation, OnlineSafetyClassification)> {
if !classifier_applies(ctx) {
let mut out: Vec<(SchemaOperation, OnlineSafetyClassification)> =
Vec::with_capacity(ops.len());
for op in ops {
if is_pk_type_flip_operation(op) {
continue;
}
out.push((op.clone(), OnlineSafetyClassification::OnlineSafe));
}
return out;
}
let mut fk_addition_counts: BTreeMap<&str, u32> = BTreeMap::new();
for op in ops {
if let SchemaOperation::AddForeignKey { table, .. } = op {
*fk_addition_counts.entry(table.as_str()).or_default() += 1;
}
}
let mut out: Vec<(SchemaOperation, OnlineSafetyClassification)> = Vec::with_capacity(ops.len());
for op in ops {
if is_pk_type_flip_operation(op) {
continue;
}
let mut verdict = classify_operation(op, ctx);
if let SchemaOperation::AddForeignKey { table, .. } = op
&& let Some(count) = fk_addition_counts.get(table.as_str())
&& *count >= ctx.multi_fk_threshold
{
verdict = OnlineSafetyClassification::ExpandContract;
}
if index_replacement_requires_refusal(op, ops) {
verdict = OnlineSafetyClassification::OfflineOnly;
}
out.push((op.clone(), verdict));
}
out
}
fn is_pk_type_flip_operation(op: &SchemaOperation) -> bool {
matches!(
op,
SchemaOperation::PkTypeFlip { .. }
| SchemaOperation::PkTypeFlipGroup(_)
| SchemaOperation::PkTypeFlipMultiGroup(_)
)
}
fn classifier_applies(ctx: &ClassifyContext<'_>) -> bool {
match ctx.target_database {
TargetDatabase::Application => true,
TargetDatabase::CrudLog => matches!(ctx.logging_profile, LoggingProfile::StrictAudit),
TargetDatabase::EventLog => false,
}
}
fn classify_add_column(
table: &str,
column: &ColumnSchema,
ctx: &ClassifyContext<'_>,
) -> OnlineSafetyClassification {
if column.generated.is_some() {
return match ctx.estimated_rows {
Some(0) => OnlineSafetyClassification::OnlineSafe,
_ => OnlineSafetyClassification::OfflineOnly,
};
}
let Some(default) = column.default_sql.as_deref() else {
if column.nullable {
return OnlineSafetyClassification::OnlineSafe;
}
return OnlineSafetyClassification::ExpandContract;
};
if let Some(override_volatility) = ctx
.default_volatility_overrides
.get(&(table.to_string(), column.name.clone()))
{
return match override_volatility {
DefaultVolatility::Immutable | DefaultVolatility::Stable => {
OnlineSafetyClassification::OnlineSafe
}
DefaultVolatility::Volatile => OnlineSafetyClassification::ExpandContract,
};
}
match classify_default_expression(default) {
Volatility::Immutable | Volatility::Stable => OnlineSafetyClassification::OnlineSafe,
Volatility::Volatile => OnlineSafetyClassification::ExpandContract,
}
}
fn classify_column_change(
change: &ColumnChange,
ctx: &ClassifyContext<'_>,
) -> OnlineSafetyClassification {
match change {
ColumnChange::SetNullable(now_nullable) => {
if *now_nullable {
OnlineSafetyClassification::OnlineSafe
} else {
classify_validation_against_threshold(ctx)
}
}
ColumnChange::SetDefault(_) => OnlineSafetyClassification::OnlineSafe,
ColumnChange::ChangeType { using: Some(_), .. } => OnlineSafetyClassification::OfflineOnly,
ColumnChange::ChangeType {
from,
to,
using: None,
} => classify_type_change(from, to),
ColumnChange::SetCheck { to, .. } => {
if to.is_some() {
classify_validation_against_threshold(ctx)
} else {
OnlineSafetyClassification::OnlineSafe
}
}
ColumnChange::SetUnique(new_unique) => {
if *new_unique {
OnlineSafetyClassification::ExpandContract
} else {
OnlineSafetyClassification::OnlineSafe
}
}
ColumnChange::SetIndexed(now_indexed) => {
if *now_indexed {
OnlineSafetyClassification::ExpandContract
} else {
OnlineSafetyClassification::OnlineSafe
}
}
ColumnChange::SetGenerated { .. } => OnlineSafetyClassification::OfflineOnly,
ColumnChange::SetIdentity { .. } => OnlineSafetyClassification::OnlineSafe,
ColumnChange::SetComment { .. } => OnlineSafetyClassification::OnlineSafe,
}
}
fn classify_type_change(from: &str, to: &str) -> OnlineSafetyClassification {
if from == to {
return OnlineSafetyClassification::OnlineSafe;
}
if is_binary_coercible_widening(from, to) {
return OnlineSafetyClassification::OnlineSafe;
}
if is_narrowing_or_truncating(from, to) {
return OnlineSafetyClassification::OfflineOnly;
}
OnlineSafetyClassification::ExpandContract
}
fn is_narrowing_or_truncating(from: &str, to: &str) -> bool {
let f = from.trim().to_ascii_lowercase();
let t = to.trim().to_ascii_lowercase();
if let (Some(fw), Some(tw)) = (canonical_int_width(&f), canonical_int_width(&t))
&& tw < fw
{
return true;
}
if let Some((from_kind, from_len)) = parse_varchar(&f)
&& let Some((to_kind, to_len)) = parse_varchar(&t)
&& from_kind == to_kind
&& let (Some(fl), Some(tl)) = (from_len, to_len)
&& tl < fl
{
return true;
}
if f == "text" && parse_varchar(&t).is_some() {
return true;
}
if let Some(from) = parse_numeric_params(&f)
&& let Some(to) = parse_numeric_params(&t)
{
match (from, to) {
(
NumericTypmod::Bounded {
precision: fp,
scale: fs,
},
NumericTypmod::Bounded {
precision: tp,
scale: ts,
},
) => {
let from_int_digits = fp.saturating_sub(fs);
let to_int_digits = tp.saturating_sub(ts);
if tp < fp || ts < fs || to_int_digits < from_int_digits {
return true;
}
}
(NumericTypmod::Unbounded, NumericTypmod::Bounded { .. }) => return true,
_ => {}
}
}
false
}
fn canonical_int_width(name: &str) -> Option<u8> {
match name {
"bigint" | "int8" => Some(64),
"integer" | "int" | "int4" => Some(32),
"smallint" | "int2" => Some(16),
_ => None,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum NumericTypmod {
Unbounded,
Bounded { precision: u32, scale: u32 },
}
fn parse_numeric_params(t: &str) -> Option<NumericTypmod> {
let normalized = t.trim();
let rest = normalized
.strip_prefix("numeric")
.or_else(|| normalized.strip_prefix("decimal"))?
.trim_start();
if rest.is_empty() {
return Some(NumericTypmod::Unbounded);
}
let bytes = rest.as_bytes();
if bytes.first() != Some(&b'(') || bytes.last() != Some(&b')') {
return None;
}
let inner = rest[1..rest.len() - 1].trim();
if inner.is_empty() {
return Some(NumericTypmod::Unbounded);
}
let mut parts = inner.split(',');
let p_str = parts.next()?.trim();
let precision: u32 = p_str.parse().ok()?;
let scale: u32 = match parts.next() {
Some(s_str) => s_str.trim().parse().ok()?,
None => 0,
};
if parts.next().is_some() {
return None;
}
Some(NumericTypmod::Bounded { precision, scale })
}
fn is_binary_coercible_widening(from: &str, to: &str) -> bool {
let f = from.trim().to_ascii_lowercase();
let t = to.trim().to_ascii_lowercase();
let is_widening_int = matches!(
(f.as_str(), t.as_str()),
("smallint", "integer")
| ("smallint", "bigint")
| ("integer", "bigint")
| ("int2", "int4")
| ("int2", "int8")
| ("int4", "int8")
);
if is_widening_int {
return true;
}
if let Some((from_kind, from_len)) = parse_varchar(&f)
&& let Some((to_kind, to_len)) = parse_varchar(&t)
&& from_kind == to_kind
&& let (Some(fl), Some(tl)) = (from_len, to_len)
&& tl >= fl
{
return true;
}
if parse_varchar(&f).is_some() && t == "text" {
return true;
}
false
}
fn parse_varchar(t: &str) -> Option<(&'static str, Option<u32>)> {
let normalized = t.trim();
let (kind, rest) = if let Some(rest) = normalized.strip_prefix("character varying") {
("varchar", rest.trim_start())
} else if let Some(rest) = normalized.strip_prefix("varchar") {
("varchar", rest.trim_start())
} else if let Some(rest) = normalized.strip_prefix("character") {
("char", rest.trim_start())
} else if let Some(rest) = normalized.strip_prefix("char") {
("char", rest.trim_start())
} else {
return None;
};
if rest.is_empty() {
return Some((kind, None));
}
let bytes = rest.as_bytes();
if bytes.first() != Some(&b'(') || bytes.last() != Some(&b')') {
return None;
}
let inner = &rest[1..rest.len() - 1].trim();
let len: u32 = inner.parse().ok()?;
Some((kind, Some(len)))
}
fn classify_fk_addition(ctx: &ClassifyContext<'_>) -> OnlineSafetyClassification {
classify_validation_against_threshold(ctx)
}
fn classify_validation_against_threshold(ctx: &ClassifyContext<'_>) -> OnlineSafetyClassification {
match ctx.estimated_rows {
Some(rows) if rows <= ctx.validation_threshold_rows => {
OnlineSafetyClassification::OnlineSafe
}
_ => OnlineSafetyClassification::ExpandContract,
}
}
fn classify_index_addition(
index: &IndexSchema,
ctx: &ClassifyContext<'_>,
) -> OnlineSafetyClassification {
match index.kind {
IndexKindSchema::UniqueConstraint | IndexKindSchema::UniqueIndex => {
OnlineSafetyClassification::ExpandContract
}
IndexKindSchema::NonUnique => {
if index.requires_out_of_transaction {
OnlineSafetyClassification::OnlineSafe
} else if ctx.estimated_rows == Some(0) {
OnlineSafetyClassification::OnlineSafe
} else {
OnlineSafetyClassification::ExpandContract
}
}
}
}
fn index_replacement_requires_refusal(op: &SchemaOperation, ops: &[SchemaOperation]) -> bool {
match op {
SchemaOperation::AddIndex(add) => ops.iter().any(|other| {
if let SchemaOperation::DropIndex(drop) = other {
indexes_replace_each_other(add, drop)
&& (!add.requires_out_of_transaction || !drop.requires_out_of_transaction)
} else {
false
}
}),
SchemaOperation::DropIndex(drop) => ops.iter().any(|other| {
if let SchemaOperation::AddIndex(add) = other {
indexes_replace_each_other(add, drop)
&& (!add.requires_out_of_transaction || !drop.requires_out_of_transaction)
} else {
false
}
}),
_ => false,
}
}
fn indexes_replace_each_other(add: &IndexSchema, drop: &IndexSchema) -> bool {
add.table == drop.table && index_targets_overlap(&add.target, &drop.target)
}
fn index_targets_overlap(left: &IndexTargetSchema, right: &IndexTargetSchema) -> bool {
match (left, right) {
(IndexTargetSchema::Columns(left_cols), IndexTargetSchema::Columns(right_cols)) => {
left_cols.iter().any(|left_col| {
right_cols
.iter()
.any(|right_col| left_col.name == right_col.name)
})
}
_ => false,
}
}
fn classify_drop_table(table: &str, ctx: &ClassifyContext<'_>) -> OnlineSafetyClassification {
let inbound = ctx.inbound_fk_counts.get(table).copied().unwrap_or(0);
if inbound >= ctx.multi_fk_threshold {
return OnlineSafetyClassification::ExpandContract;
}
OnlineSafetyClassification::FastLockDestructiveGuarded
}
#[cfg(test)]
mod tests {
use super::*;
use crate::migrate::diff::{ColumnChange, SchemaOperation};
use crate::migrate::schema::{
ColumnSchema, ExclusionConstraintSchema, ExclusionElementSchema, ForeignKeySchema,
GeneratedColumnSchema, IndexColumnSchema, IndexKindSchema, IndexNullsOrderSchema,
IndexOrderSchema, IndexSchema, IndexTargetSchema, IndexTypeSchema, OnDeleteSchema,
PkKindSchema,
};
fn nullable_column(name: &str) -> 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: true,
on_delete: None,
outbox_exclude: false,
rationale: None,
relation_kind: None,
renamed_from: None,
sequence_within: None,
sql_type: "TEXT".to_string(),
unique: false,
type_change_using: None,
}
}
fn non_null_column(name: &str, default: Option<&str>) -> ColumnSchema {
ColumnSchema {
nullable: false,
default_sql: default.map(|s| s.to_string()),
..nullable_column(name)
}
}
fn nullable_column_with_default(name: &str, default: &str) -> ColumnSchema {
ColumnSchema {
default_sql: Some(default.to_string()),
..nullable_column(name)
}
}
fn index(name: &str, table: &str, concurrently: bool) -> 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: concurrently,
table: table.to_string(),
target: IndexTargetSchema::Columns(Vec::new()),
}
}
fn unique_index(
name: &str,
table: &str,
kind: IndexKindSchema,
concurrently: bool,
) -> IndexSchema {
IndexSchema {
kind,
..index(name, table, concurrently)
}
}
fn index_on_column(name: &str, table: &str, column: &str, concurrently: bool) -> IndexSchema {
IndexSchema {
target: IndexTargetSchema::Columns(vec![IndexColumnSchema {
name: column.to_string(),
nulls: IndexNullsOrderSchema::Default,
opclass: None,
order: IndexOrderSchema::Asc,
}]),
..index(name, table, concurrently)
}
}
fn fk_for(table: &str) -> ForeignKeySchema {
ForeignKeySchema {
deferrable: false,
initially_deferred: false,
on_delete: OnDeleteSchema::Restrict,
ref_column: "id".to_string(),
ref_table: table.to_string(),
}
}
fn ctx_app(estimated: Option<u64>) -> (BTreeMap<String, u32>, ClassifyContext<'static>) {
let inbound: &'static BTreeMap<String, u32> = Box::leak(Box::new(BTreeMap::new()));
let overrides: &'static BTreeMap<(String, String), DefaultVolatility> =
Box::leak(Box::new(BTreeMap::new()));
(
BTreeMap::new(),
ClassifyContext {
estimated_rows: estimated,
validation_threshold_rows: 100_000,
multi_fk_threshold: 4,
logging_profile: LoggingProfile::Balanced,
target_database: TargetDatabase::Application,
inbound_fk_counts: inbound,
default_volatility_overrides: overrides,
},
)
}
#[test]
fn add_nullable_column_classifies_as_online_safe() {
let (_unused, ctx) = ctx_app(Some(0));
let op = SchemaOperation::AddColumn {
table: "users".to_string(),
column: nullable_column("nickname"),
};
assert_eq!(
classify_operation(&op, &ctx),
OnlineSafetyClassification::OnlineSafe
);
}
#[test]
fn add_non_null_column_with_constant_default_is_online_safe() {
let (_unused, ctx) = ctx_app(Some(0));
let op = SchemaOperation::AddColumn {
table: "users".to_string(),
column: non_null_column("score", Some("0")),
};
assert_eq!(
classify_operation(&op, &ctx),
OnlineSafetyClassification::OnlineSafe
);
}
#[test]
fn ddl_metadata_catalog_writes_classify_online_safe() {
let (_unused, ctx) = ctx_app(Some(10));
let table_comment = SchemaOperation::SetTableComment {
table: "users".to_string(),
from: None,
to: Some("Users table".to_string()),
};
let storage_params = SchemaOperation::SetStorageParams {
table: "users".to_string(),
from: None,
to: Some("fillfactor=70".to_string()),
};
assert_eq!(
classify_operation(&table_comment, &ctx),
OnlineSafetyClassification::OnlineSafe
);
assert_eq!(
classify_operation(&storage_params, &ctx),
OnlineSafetyClassification::OnlineSafe
);
}
#[test]
fn set_tablespace_classifies_offline_only() {
let (_unused, ctx) = ctx_app(Some(10));
let op = SchemaOperation::SetTablespace {
table: "users".to_string(),
from: None,
to: Some("fastspace".to_string()),
};
assert_eq!(
classify_operation(&op, &ctx),
OnlineSafetyClassification::OfflineOnly
);
}
#[test]
fn add_non_null_column_with_now_default_is_online_safe() {
let (_unused, ctx) = ctx_app(Some(0));
let op = SchemaOperation::AddColumn {
table: "users".to_string(),
column: non_null_column("created_at", Some("now()")),
};
assert_eq!(
classify_operation(&op, &ctx),
OnlineSafetyClassification::OnlineSafe
);
}
#[test]
fn add_non_null_column_with_volatile_default_is_expand_contract() {
let (_unused, ctx) = ctx_app(Some(0));
let op = SchemaOperation::AddColumn {
table: "users".to_string(),
column: non_null_column("token", Some("gen_random_uuid()")),
};
assert_eq!(
classify_operation(&op, &ctx),
OnlineSafetyClassification::ExpandContract
);
}
#[test]
fn add_nullable_column_with_random_default_is_expand_contract() {
let (_unused, ctx) = ctx_app(Some(0));
let op = SchemaOperation::AddColumn {
table: "users".to_string(),
column: nullable_column_with_default("seed", "random()"),
};
assert_eq!(
classify_operation(&op, &ctx),
OnlineSafetyClassification::ExpandContract
);
}
#[test]
fn add_nullable_column_with_gen_random_uuid_default_is_expand_contract() {
let (_unused, ctx) = ctx_app(Some(0));
let op = SchemaOperation::AddColumn {
table: "users".to_string(),
column: nullable_column_with_default("token", "gen_random_uuid()"),
};
assert_eq!(
classify_operation(&op, &ctx),
OnlineSafetyClassification::ExpandContract
);
}
#[test]
fn add_nullable_column_with_clock_timestamp_default_is_expand_contract() {
let (_unused, ctx) = ctx_app(Some(0));
let op = SchemaOperation::AddColumn {
table: "events".to_string(),
column: nullable_column_with_default("logged_at", "clock_timestamp()"),
};
assert_eq!(
classify_operation(&op, &ctx),
OnlineSafetyClassification::ExpandContract
);
}
#[test]
fn add_nullable_column_with_stable_default_is_online_safe() {
let (_unused, ctx) = ctx_app(Some(0));
let op = SchemaOperation::AddColumn {
table: "events".to_string(),
column: nullable_column_with_default("logged_at", "now()"),
};
assert_eq!(
classify_operation(&op, &ctx),
OnlineSafetyClassification::OnlineSafe
);
}
#[test]
fn add_non_null_column_without_default_is_expand_contract() {
let (_unused, ctx) = ctx_app(Some(0));
let op = SchemaOperation::AddColumn {
table: "users".to_string(),
column: non_null_column("required", None),
};
assert_eq!(
classify_operation(&op, &ctx),
OnlineSafetyClassification::ExpandContract
);
}
#[test]
fn drop_column_is_fast_lock_destructive_guarded() {
let (_unused, ctx) = ctx_app(Some(0));
let op = SchemaOperation::DropColumn {
table: "users".to_string(),
column: "old".to_string(),
};
assert_eq!(
classify_operation(&op, &ctx),
OnlineSafetyClassification::FastLockDestructiveGuarded
);
}
#[test]
fn rename_column_is_online_safe() {
let (_unused, ctx) = ctx_app(Some(0));
let op = SchemaOperation::RenameColumn {
table: "users".to_string(),
from: "name".to_string(),
to: "full_name".to_string(),
};
assert_eq!(
classify_operation(&op, &ctx),
OnlineSafetyClassification::OnlineSafe
);
}
#[test]
fn tighten_nullability_above_threshold_is_expand_contract() {
let (_unused, ctx) = ctx_app(Some(500_000));
let op = SchemaOperation::AlterColumn {
table: "users".to_string(),
column: "email".to_string(),
change: ColumnChange::SetNullable(false),
};
assert_eq!(
classify_operation(&op, &ctx),
OnlineSafetyClassification::ExpandContract
);
}
#[test]
fn tighten_nullability_below_threshold_is_online_safe() {
let (_unused, ctx) = ctx_app(Some(50_000));
let op = SchemaOperation::AlterColumn {
table: "users".to_string(),
column: "email".to_string(),
change: ColumnChange::SetNullable(false),
};
assert_eq!(
classify_operation(&op, &ctx),
OnlineSafetyClassification::OnlineSafe
);
}
#[test]
fn tighten_nullability_unknown_rows_is_expand_contract() {
let (_unused, ctx) = ctx_app(None);
let op = SchemaOperation::AlterColumn {
table: "users".to_string(),
column: "email".to_string(),
change: ColumnChange::SetNullable(false),
};
assert_eq!(
classify_operation(&op, &ctx),
OnlineSafetyClassification::ExpandContract
);
}
#[test]
fn relax_nullability_is_online_safe() {
let (_unused, ctx) = ctx_app(Some(0));
let op = SchemaOperation::AlterColumn {
table: "users".to_string(),
column: "email".to_string(),
change: ColumnChange::SetNullable(true),
};
assert_eq!(
classify_operation(&op, &ctx),
OnlineSafetyClassification::OnlineSafe
);
}
#[test]
fn integer_widening_is_online_safe() {
let (_unused, ctx) = ctx_app(Some(0));
let op = SchemaOperation::AlterColumn {
table: "users".to_string(),
column: "id".to_string(),
change: ColumnChange::ChangeType {
from: "integer".to_string(),
to: "bigint".to_string(),
using: None,
},
};
assert_eq!(
classify_operation(&op, &ctx),
OnlineSafetyClassification::OnlineSafe
);
}
#[test]
fn varchar_widening_is_online_safe() {
let (_unused, ctx) = ctx_app(Some(0));
let op = SchemaOperation::AlterColumn {
table: "users".to_string(),
column: "name".to_string(),
change: ColumnChange::ChangeType {
from: "varchar(64)".to_string(),
to: "varchar(128)".to_string(),
using: None,
},
};
assert_eq!(
classify_operation(&op, &ctx),
OnlineSafetyClassification::OnlineSafe
);
}
#[test]
fn varchar_to_text_is_online_safe() {
let (_unused, ctx) = ctx_app(Some(0));
let op = SchemaOperation::AlterColumn {
table: "users".to_string(),
column: "bio".to_string(),
change: ColumnChange::ChangeType {
from: "varchar(255)".to_string(),
to: "text".to_string(),
using: None,
},
};
assert_eq!(
classify_operation(&op, &ctx),
OnlineSafetyClassification::OnlineSafe
);
}
#[test]
fn text_to_varchar_is_offline_only() {
let (_unused, ctx) = ctx_app(Some(0));
let op = SchemaOperation::AlterColumn {
table: "users".to_string(),
column: "bio".to_string(),
change: ColumnChange::ChangeType {
from: "text".to_string(),
to: "varchar(255)".to_string(),
using: None,
},
};
assert_eq!(
classify_operation(&op, &ctx),
OnlineSafetyClassification::OfflineOnly
);
}
#[test]
fn bigint_to_int4_is_offline_only() {
let (_unused, ctx) = ctx_app(Some(0));
let op = SchemaOperation::AlterColumn {
table: "metrics".to_string(),
column: "count".to_string(),
change: ColumnChange::ChangeType {
from: "bigint".to_string(),
to: "integer".to_string(),
using: None,
},
};
assert_eq!(
classify_operation(&op, &ctx),
OnlineSafetyClassification::OfflineOnly
);
}
#[test]
fn int4_to_smallint_is_offline_only() {
let (_unused, ctx) = ctx_app(Some(0));
let op = SchemaOperation::AlterColumn {
table: "metrics".to_string(),
column: "count".to_string(),
change: ColumnChange::ChangeType {
from: "integer".to_string(),
to: "smallint".to_string(),
using: None,
},
};
assert_eq!(
classify_operation(&op, &ctx),
OnlineSafetyClassification::OfflineOnly
);
}
#[test]
fn type_change_with_adopter_using_is_offline_only() {
let (_unused, ctx) = ctx_app(Some(0));
let op = SchemaOperation::AlterColumn {
table: "metrics".to_string(),
column: "count".to_string(),
change: ColumnChange::ChangeType {
from: "integer".to_string(),
to: "bigint".to_string(),
using: Some("count::BIGINT".to_string()),
},
};
assert_eq!(
classify_operation(&op, &ctx),
OnlineSafetyClassification::OfflineOnly,
"adopter `using` must force OfflineOnly regardless of cast pair",
);
}
#[test]
fn varchar_narrowing_is_offline_only() {
let (_unused, ctx) = ctx_app(Some(0));
let op = SchemaOperation::AlterColumn {
table: "users".to_string(),
column: "tag".to_string(),
change: ColumnChange::ChangeType {
from: "varchar(20)".to_string(),
to: "varchar(10)".to_string(),
using: None,
},
};
assert_eq!(
classify_operation(&op, &ctx),
OnlineSafetyClassification::OfflineOnly
);
}
#[test]
fn numeric_precision_loss_is_offline_only() {
let (_unused, ctx) = ctx_app(Some(0));
let op = SchemaOperation::AlterColumn {
table: "ledger".to_string(),
column: "amount".to_string(),
change: ColumnChange::ChangeType {
from: "numeric(20, 4)".to_string(),
to: "numeric(10, 4)".to_string(),
using: None,
},
};
assert_eq!(
classify_operation(&op, &ctx),
OnlineSafetyClassification::OfflineOnly
);
}
#[test]
fn numeric_scale_loss_is_offline_only() {
let (_unused, ctx) = ctx_app(Some(0));
let op = SchemaOperation::AlterColumn {
table: "ledger".to_string(),
column: "amount".to_string(),
change: ColumnChange::ChangeType {
from: "numeric(20, 4)".to_string(),
to: "numeric(20, 2)".to_string(),
using: None,
},
};
assert_eq!(
classify_operation(&op, &ctx),
OnlineSafetyClassification::OfflineOnly
);
}
#[test]
fn numeric_scale_addition_preserving_integer_digits_is_widening() {
let (_unused, ctx) = ctx_app(Some(0));
let op = SchemaOperation::AlterColumn {
table: "ledger".to_string(),
column: "amount".to_string(),
change: ColumnChange::ChangeType {
from: "numeric(10)".to_string(),
to: "numeric(12, 2)".to_string(),
using: None,
},
};
assert_ne!(
classify_operation(&op, &ctx),
OnlineSafetyClassification::OfflineOnly
);
}
#[test]
fn numeric_integer_digit_room_loss_is_offline_only() {
let (_unused, ctx) = ctx_app(Some(0));
let op = SchemaOperation::AlterColumn {
table: "ledger".to_string(),
column: "amount".to_string(),
change: ColumnChange::ChangeType {
from: "numeric(10)".to_string(),
to: "numeric(10, 2)".to_string(),
using: None,
},
};
assert_eq!(
classify_operation(&op, &ctx),
OnlineSafetyClassification::OfflineOnly
);
}
#[test]
fn bigint_to_int4_alias_is_offline_only() {
let (_unused, ctx) = ctx_app(Some(0));
for (from, to) in [
("bigint", "int4"),
("bigint", "int2"),
("int8", "integer"),
("int8", "smallint"),
("int4", "smallint"),
("integer", "int2"),
] {
let op = SchemaOperation::AlterColumn {
table: "metrics".to_string(),
column: "count".to_string(),
change: ColumnChange::ChangeType {
from: from.to_string(),
to: to.to_string(),
using: None,
},
};
assert_eq!(
classify_operation(&op, &ctx),
OnlineSafetyClassification::OfflineOnly,
"{from} -> {to}"
);
}
}
#[test]
fn unbounded_numeric_to_bounded_is_offline_only() {
let (_unused, ctx) = ctx_app(Some(0));
let op = SchemaOperation::AlterColumn {
table: "ledger".to_string(),
column: "amount".to_string(),
change: ColumnChange::ChangeType {
from: "numeric".to_string(),
to: "numeric(10, 2)".to_string(),
using: None,
},
};
assert_eq!(
classify_operation(&op, &ctx),
OnlineSafetyClassification::OfflineOnly
);
}
#[test]
fn unknown_type_change_remains_expand_contract() {
let (_unused, ctx) = ctx_app(Some(0));
let op = SchemaOperation::AlterColumn {
table: "users".to_string(),
column: "status".to_string(),
change: ColumnChange::ChangeType {
from: "user_status_v1".to_string(),
to: "user_status_v2".to_string(),
using: None,
},
};
assert_eq!(
classify_operation(&op, &ctx),
OnlineSafetyClassification::ExpandContract
);
}
#[test]
fn add_fk_below_threshold_is_online_safe() {
let (_unused, ctx) = ctx_app(Some(50_000));
let op = SchemaOperation::AddForeignKey {
table: "posts".to_string(),
column: "author_id".to_string(),
fk: fk_for("authors"),
};
assert_eq!(
classify_operation(&op, &ctx),
OnlineSafetyClassification::OnlineSafe
);
}
#[test]
fn add_fk_above_threshold_is_expand_contract() {
let (_unused, ctx) = ctx_app(Some(500_000));
let op = SchemaOperation::AddForeignKey {
table: "posts".to_string(),
column: "author_id".to_string(),
fk: fk_for("authors"),
};
assert_eq!(
classify_operation(&op, &ctx),
OnlineSafetyClassification::ExpandContract
);
}
#[test]
fn add_fk_unknown_rows_is_expand_contract() {
let (_unused, ctx) = ctx_app(None);
let op = SchemaOperation::AddForeignKey {
table: "posts".to_string(),
column: "author_id".to_string(),
fk: fk_for("authors"),
};
assert_eq!(
classify_operation(&op, &ctx),
OnlineSafetyClassification::ExpandContract
);
}
#[test]
fn add_concurrent_index_is_online_safe() {
let (_unused, ctx) = ctx_app(Some(0));
let op = SchemaOperation::AddIndex(index("ix_a", "users", true));
assert_eq!(
classify_operation(&op, &ctx),
OnlineSafetyClassification::OnlineSafe
);
}
#[test]
fn add_non_concurrent_index_on_populated_table_is_expand_contract() {
let (_unused, ctx) = ctx_app(Some(50_000));
let op = SchemaOperation::AddIndex(index("ix_a", "users", false));
assert_eq!(
classify_operation(&op, &ctx),
OnlineSafetyClassification::ExpandContract
);
}
#[test]
fn add_non_concurrent_index_on_empty_table_is_online_safe() {
let (_unused, ctx) = ctx_app(Some(0));
let op = SchemaOperation::AddIndex(index("ix_a", "users", false));
assert_eq!(
classify_operation(&op, &ctx),
OnlineSafetyClassification::OnlineSafe
);
}
#[test]
fn add_non_concurrent_index_with_unknown_row_count_is_expand_contract() {
let (_unused, ctx) = ctx_app(None);
let op = SchemaOperation::AddIndex(index("ix_a", "users", false));
assert_eq!(
classify_operation(&op, &ctx),
OnlineSafetyClassification::ExpandContract
);
}
#[test]
fn add_concurrent_unique_constraint_is_expand_contract() {
let (_unused, ctx) = ctx_app(Some(0));
let op = SchemaOperation::AddIndex(unique_index(
"ux_email",
"users",
IndexKindSchema::UniqueConstraint,
true,
));
let verdict = classify_operation(&op, &ctx);
assert_ne!(
verdict,
OnlineSafetyClassification::OnlineSafe,
"unique constraint must not short-circuit to OnlineSafe even when built concurrently"
);
assert_eq!(verdict, OnlineSafetyClassification::ExpandContract);
}
#[test]
fn add_concurrent_unique_index_is_expand_contract() {
let (_unused, ctx) = ctx_app(Some(0));
let op = SchemaOperation::AddIndex(unique_index(
"ux_email_partial",
"users",
IndexKindSchema::UniqueIndex,
true,
));
assert_eq!(
classify_operation(&op, &ctx),
OnlineSafetyClassification::ExpandContract
);
}
#[test]
fn add_non_concurrent_unique_constraint_is_expand_contract() {
let (_unused, ctx) = ctx_app(Some(0));
let op = SchemaOperation::AddIndex(unique_index(
"ux_email",
"users",
IndexKindSchema::UniqueConstraint,
false,
));
assert_eq!(
classify_operation(&op, &ctx),
OnlineSafetyClassification::ExpandContract
);
}
#[test]
fn drop_table_few_inbound_fks_is_destructive_guarded() {
let (_unused, ctx) = ctx_app(Some(0));
let op = SchemaOperation::DropTable("legacy".to_string());
assert_eq!(
classify_operation(&op, &ctx),
OnlineSafetyClassification::FastLockDestructiveGuarded
);
}
#[test]
fn drop_table_many_inbound_fks_is_expand_contract() {
let mut inbound: BTreeMap<String, u32> = BTreeMap::new();
inbound.insert("legacy".to_string(), 5);
let inbound: &'static BTreeMap<String, u32> = Box::leak(Box::new(inbound));
let overrides: &'static BTreeMap<(String, String), DefaultVolatility> =
Box::leak(Box::new(BTreeMap::new()));
let ctx = ClassifyContext {
estimated_rows: Some(0),
validation_threshold_rows: 100_000,
multi_fk_threshold: 4,
logging_profile: LoggingProfile::Balanced,
target_database: TargetDatabase::Application,
inbound_fk_counts: inbound,
default_volatility_overrides: overrides,
};
let op = SchemaOperation::DropTable("legacy".to_string());
assert_eq!(
classify_operation(&op, &ctx),
OnlineSafetyClassification::ExpandContract
);
}
#[test]
fn add_enum_variant_is_online_safe() {
let (_unused, ctx) = ctx_app(Some(0));
let op = SchemaOperation::AddEnumVariant {
enum_name: "status".to_string(),
variant: "ARCHIVED".to_string(),
anchor: None,
};
assert_eq!(
classify_operation(&op, &ctx),
OnlineSafetyClassification::OnlineSafe
);
}
#[test]
fn drop_enum_is_offline_only() {
let (_unused, ctx) = ctx_app(Some(0));
let op = SchemaOperation::DropEnum("status".to_string());
assert_eq!(
classify_operation(&op, &ctx),
OnlineSafetyClassification::OfflineOnly
);
}
#[test]
fn unsupported_op_is_offline_only() {
let (_unused, ctx) = ctx_app(Some(0));
let op = SchemaOperation::Unsupported {
reason: "partition method change".to_string(),
};
assert_eq!(
classify_operation(&op, &ctx),
OnlineSafetyClassification::OfflineOnly
);
}
#[test]
fn classifier_is_deterministic() {
let (_unused, ctx) = ctx_app(Some(0));
let op = SchemaOperation::AddColumn {
table: "users".to_string(),
column: non_null_column("token", Some("gen_random_uuid()")),
};
let first = classify_operation(&op, &ctx);
let second = classify_operation(&op, &ctx);
let third = classify_operation(&op, &ctx);
assert_eq!(first, second);
assert_eq!(second, third);
assert_eq!(first, OnlineSafetyClassification::ExpandContract);
}
#[test]
fn event_log_target_short_circuits_to_online_safe() {
let inbound: &'static BTreeMap<String, u32> = Box::leak(Box::new(BTreeMap::new()));
let overrides: &'static BTreeMap<(String, String), DefaultVolatility> =
Box::leak(Box::new(BTreeMap::new()));
let ctx = ClassifyContext {
estimated_rows: Some(0),
validation_threshold_rows: 100_000,
multi_fk_threshold: 4,
logging_profile: LoggingProfile::StrictAudit,
target_database: TargetDatabase::EventLog,
inbound_fk_counts: inbound,
default_volatility_overrides: overrides,
};
let op = SchemaOperation::AddColumn {
table: "events".to_string(),
column: non_null_column("required", None),
};
assert_eq!(
classify_operation(&op, &ctx),
OnlineSafetyClassification::OnlineSafe
);
}
#[test]
fn event_log_delta_short_circuit_is_not_overridden_by_multi_fk_aggregation() {
let inbound: &'static BTreeMap<String, u32> = Box::leak(Box::new(BTreeMap::new()));
let overrides: &'static BTreeMap<(String, String), DefaultVolatility> =
Box::leak(Box::new(BTreeMap::new()));
let ctx = ClassifyContext {
estimated_rows: Some(50),
validation_threshold_rows: 100_000,
multi_fk_threshold: 4,
logging_profile: LoggingProfile::StrictAudit,
target_database: TargetDatabase::EventLog,
inbound_fk_counts: inbound,
default_volatility_overrides: overrides,
};
let ops: Vec<SchemaOperation> = (0..4)
.map(|i| SchemaOperation::AddForeignKey {
table: "events".to_string(),
column: format!("ref_{i}"),
fk: fk_for("authors"),
})
.collect();
let out = classify_delta(&ops, &ctx);
for (_op, verdict) in &out {
assert_eq!(*verdict, OnlineSafetyClassification::OnlineSafe);
}
}
#[test]
fn crud_log_under_balanced_short_circuits() {
let inbound: &'static BTreeMap<String, u32> = Box::leak(Box::new(BTreeMap::new()));
let overrides: &'static BTreeMap<(String, String), DefaultVolatility> =
Box::leak(Box::new(BTreeMap::new()));
let ctx = ClassifyContext {
estimated_rows: Some(0),
validation_threshold_rows: 100_000,
multi_fk_threshold: 4,
logging_profile: LoggingProfile::Balanced,
target_database: TargetDatabase::CrudLog,
inbound_fk_counts: inbound,
default_volatility_overrides: overrides,
};
let op = SchemaOperation::DropColumn {
table: "users_log".to_string(),
column: "old".to_string(),
};
assert_eq!(
classify_operation(&op, &ctx),
OnlineSafetyClassification::OnlineSafe
);
}
#[test]
fn crud_log_under_strict_audit_runs_full_classifier() {
let inbound: &'static BTreeMap<String, u32> = Box::leak(Box::new(BTreeMap::new()));
let overrides: &'static BTreeMap<(String, String), DefaultVolatility> =
Box::leak(Box::new(BTreeMap::new()));
let ctx = ClassifyContext {
estimated_rows: Some(0),
validation_threshold_rows: 100_000,
multi_fk_threshold: 4,
logging_profile: LoggingProfile::StrictAudit,
target_database: TargetDatabase::CrudLog,
inbound_fk_counts: inbound,
default_volatility_overrides: overrides,
};
let op = SchemaOperation::DropColumn {
table: "users_log".to_string(),
column: "old".to_string(),
};
assert_eq!(
classify_operation(&op, &ctx),
OnlineSafetyClassification::FastLockDestructiveGuarded
);
}
#[test]
fn classify_delta_skips_pk_flip_groups() {
use crate::migrate::diff::{PkFlipDirection, PkFlipJoinTableOption, PkTypeFlipGroup};
use crate::migrate::schema::PkKindSchema;
let (_unused, ctx) = ctx_app(Some(0));
let group = PkTypeFlipGroup {
parent_table: "users".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: PkFlipJoinTableOption::OptionA,
};
let ops = vec![
SchemaOperation::PkTypeFlipGroup(group),
SchemaOperation::AddColumn {
table: "users".to_string(),
column: nullable_column("nickname"),
},
];
let out = classify_delta(&ops, &ctx);
assert_eq!(out.len(), 1, "PkTypeFlipGroup must be filtered out");
assert!(matches!(out[0].0, SchemaOperation::AddColumn { .. }));
assert_eq!(out[0].1, OnlineSafetyClassification::OnlineSafe);
}
#[test]
fn classify_delta_escalates_multi_fk_additions() {
let (_unused, ctx) = ctx_app(Some(50)); let ops: Vec<SchemaOperation> = (0..4)
.map(|i| SchemaOperation::AddForeignKey {
table: "posts".to_string(),
column: format!("ref_{i}"),
fk: fk_for("authors"),
})
.collect();
let out = classify_delta(&ops, &ctx);
assert_eq!(out.len(), 4);
for (_op, verdict) in &out {
assert_eq!(*verdict, OnlineSafetyClassification::ExpandContract);
}
}
#[test]
fn set_check_below_threshold_is_online_safe() {
let (_unused, ctx) = ctx_app(Some(50_000));
let op = SchemaOperation::AlterColumn {
table: "users".to_string(),
column: "age".to_string(),
change: ColumnChange::SetCheck {
from: None,
to: Some("age >= 0".to_string()),
},
};
assert_eq!(
classify_operation(&op, &ctx),
OnlineSafetyClassification::OnlineSafe
);
}
#[test]
fn set_check_above_threshold_is_expand_contract() {
let (_unused, ctx) = ctx_app(Some(200_000));
let op = SchemaOperation::AlterColumn {
table: "users".to_string(),
column: "age".to_string(),
change: ColumnChange::SetCheck {
from: None,
to: Some("age >= 0".to_string()),
},
};
assert_eq!(
classify_operation(&op, &ctx),
OnlineSafetyClassification::ExpandContract
);
}
#[test]
fn set_check_unknown_rows_is_expand_contract() {
let (_unused, ctx) = ctx_app(None);
let op = SchemaOperation::AlterColumn {
table: "users".to_string(),
column: "age".to_string(),
change: ColumnChange::SetCheck {
from: None,
to: Some("age >= 0".to_string()),
},
};
assert_eq!(
classify_operation(&op, &ctx),
OnlineSafetyClassification::ExpandContract
);
}
#[test]
fn set_check_drop_is_online_safe_regardless_of_prior() {
let (_unused, ctx) = ctx_app(Some(200_000));
let op = SchemaOperation::AlterColumn {
table: "users".to_string(),
column: "age".to_string(),
change: ColumnChange::SetCheck {
from: Some("age >= 0".to_string()),
to: None,
},
};
assert_eq!(
classify_operation(&op, &ctx),
OnlineSafetyClassification::OnlineSafe
);
}
#[test]
fn default_volatility_override_stable_routes_to_online_safe() {
let inbound: &'static BTreeMap<String, u32> = Box::leak(Box::new(BTreeMap::new()));
let mut overrides_map: BTreeMap<(String, String), DefaultVolatility> = BTreeMap::new();
overrides_map.insert(
("users".to_string(), "token".to_string()),
DefaultVolatility::Stable,
);
let overrides: &'static BTreeMap<(String, String), DefaultVolatility> =
Box::leak(Box::new(overrides_map));
let ctx = ClassifyContext {
estimated_rows: Some(0),
validation_threshold_rows: 100_000,
multi_fk_threshold: 4,
logging_profile: LoggingProfile::Balanced,
target_database: TargetDatabase::Application,
inbound_fk_counts: inbound,
default_volatility_overrides: overrides,
};
let op = SchemaOperation::AddColumn {
table: "users".to_string(),
column: non_null_column("token", Some("gen_random_uuid()")),
};
assert_eq!(
classify_operation(&op, &ctx),
OnlineSafetyClassification::OnlineSafe
);
}
#[test]
fn default_volatility_override_immutable_routes_to_online_safe() {
let inbound: &'static BTreeMap<String, u32> = Box::leak(Box::new(BTreeMap::new()));
let mut overrides_map: BTreeMap<(String, String), DefaultVolatility> = BTreeMap::new();
overrides_map.insert(
("users".to_string(), "token".to_string()),
DefaultVolatility::Immutable,
);
let overrides: &'static BTreeMap<(String, String), DefaultVolatility> =
Box::leak(Box::new(overrides_map));
let ctx = ClassifyContext {
estimated_rows: Some(0),
validation_threshold_rows: 100_000,
multi_fk_threshold: 4,
logging_profile: LoggingProfile::Balanced,
target_database: TargetDatabase::Application,
inbound_fk_counts: inbound,
default_volatility_overrides: overrides,
};
let op = SchemaOperation::AddColumn {
table: "users".to_string(),
column: non_null_column("token", Some("my_pure_udf()")),
};
assert_eq!(
classify_operation(&op, &ctx),
OnlineSafetyClassification::OnlineSafe
);
}
#[test]
fn default_volatility_override_absent_falls_through_to_static_table() {
let (_unused, ctx) = ctx_app(Some(0));
let op = SchemaOperation::AddColumn {
table: "users".to_string(),
column: non_null_column("token", Some("gen_random_uuid()")),
};
assert_eq!(
classify_operation(&op, &ctx),
OnlineSafetyClassification::ExpandContract
);
}
#[test]
fn pk_type_flip_dispatch_returns_offline_only_to_refuse_misuse() {
use crate::migrate::diff::{PkFlipDirection, PkFlipJoinTableOption, PkTypeFlipGroup};
use crate::migrate::schema::PkKindSchema;
let (_unused, ctx) = ctx_app(Some(0));
let group = PkTypeFlipGroup {
parent_table: "users".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: PkFlipJoinTableOption::OptionA,
};
let op = SchemaOperation::PkTypeFlipGroup(group);
assert_eq!(
classify_operation(&op, &ctx),
OnlineSafetyClassification::OfflineOnly
);
}
#[test]
fn pk_type_flip_dispatch_refuses_even_when_target_short_circuits() {
let inbound: &'static BTreeMap<String, u32> = Box::leak(Box::new(BTreeMap::new()));
let overrides: &'static BTreeMap<(String, String), DefaultVolatility> =
Box::leak(Box::new(BTreeMap::new()));
let ctx = ClassifyContext {
estimated_rows: Some(0),
validation_threshold_rows: 100_000,
multi_fk_threshold: 4,
logging_profile: LoggingProfile::Balanced,
target_database: TargetDatabase::EventLog,
inbound_fk_counts: inbound,
default_volatility_overrides: overrides,
};
let op = SchemaOperation::PkTypeFlip {
table: "users".to_string(),
from: PkKindSchema::HeerId,
to: PkKindSchema::HeerIdRecencyBiased,
};
assert_eq!(
classify_operation(&op, &ctx),
OnlineSafetyClassification::OfflineOnly
);
}
#[test]
fn replacement_index_with_blocking_side_is_offline_only() {
let (_unused, ctx) = ctx_app(Some(50));
let ops = vec![
SchemaOperation::DropIndex(index_on_column("ix_old", "users", "email", false)),
SchemaOperation::AddIndex(index_on_column("ix_new", "users", "email", true)),
];
let out = classify_delta(&ops, &ctx);
assert_eq!(out.len(), 2);
for (_op, verdict) in &out {
assert_eq!(*verdict, OnlineSafetyClassification::OfflineOnly);
}
}
#[test]
fn classify_delta_does_not_escalate_below_multi_fk_threshold() {
let (_unused, ctx) = ctx_app(Some(50));
let ops: Vec<SchemaOperation> = (0..3)
.map(|i| SchemaOperation::AddForeignKey {
table: "posts".to_string(),
column: format!("ref_{i}"),
fk: fk_for("authors"),
})
.collect();
let out = classify_delta(&ops, &ctx);
for (_op, verdict) in &out {
assert_eq!(*verdict, OnlineSafetyClassification::OnlineSafe);
}
}
fn exclusion(name: &str) -> ExclusionConstraintSchema {
ExclusionConstraintSchema {
deferrable: false,
elements: vec![ExclusionElementSchema {
expr: "room_id".to_string(),
with_operator: "=".to_string(),
}],
extension_dependency: None,
initially_deferred: false,
name: name.to_string(),
using: "gist".to_string(),
where_clause: None,
}
}
fn generated_column(name: &str, expression: &str) -> ColumnSchema {
ColumnSchema {
generated: Some(GeneratedColumnSchema {
expression: expression.to_string(),
stored: true,
}),
..nullable_column(name)
}
}
#[test]
fn add_exclusion_constraint_on_empty_table_is_online_safe() {
let (_unused, ctx) = ctx_app(Some(0));
let op = SchemaOperation::AddExclusionConstraint {
table: "bookings".to_string(),
exclusion: exclusion("no_overlap"),
};
assert_eq!(
classify_operation(&op, &ctx),
OnlineSafetyClassification::OnlineSafe,
);
}
#[test]
fn add_exclusion_constraint_on_populated_table_is_offline_only() {
let (_unused, ctx) = ctx_app(Some(1_000_000));
let op = SchemaOperation::AddExclusionConstraint {
table: "bookings".to_string(),
exclusion: exclusion("no_overlap"),
};
assert_eq!(
classify_operation(&op, &ctx),
OnlineSafetyClassification::OfflineOnly,
);
}
#[test]
fn add_exclusion_constraint_with_unknown_row_count_is_offline_only() {
let (_unused, ctx) = ctx_app(None);
let op = SchemaOperation::AddExclusionConstraint {
table: "bookings".to_string(),
exclusion: exclusion("no_overlap"),
};
assert_eq!(
classify_operation(&op, &ctx),
OnlineSafetyClassification::OfflineOnly,
);
}
#[test]
fn drop_exclusion_constraint_classifies_as_online_safe() {
let (_unused, ctx) = ctx_app(Some(1_000_000));
let op = SchemaOperation::DropExclusionConstraint {
table: "bookings".to_string(),
name: "no_overlap".to_string(),
exclusion: exclusion("no_overlap"),
};
assert_eq!(
classify_operation(&op, &ctx),
OnlineSafetyClassification::OnlineSafe,
);
}
#[test]
fn add_stored_generated_column_on_empty_table_is_online_safe() {
let (_unused, ctx) = ctx_app(Some(0));
let op = SchemaOperation::AddColumn {
table: "users".to_string(),
column: generated_column("email_lower", "LOWER(email)"),
};
assert_eq!(
classify_operation(&op, &ctx),
OnlineSafetyClassification::OnlineSafe,
);
}
#[test]
fn add_stored_generated_column_on_populated_table_is_offline_only() {
let (_unused, ctx) = ctx_app(Some(50_000));
let op = SchemaOperation::AddColumn {
table: "users".to_string(),
column: generated_column("email_lower", "LOWER(email)"),
};
assert_eq!(
classify_operation(&op, &ctx),
OnlineSafetyClassification::OfflineOnly,
);
}
#[test]
fn add_stored_generated_column_with_unknown_row_count_is_offline_only() {
let (_unused, ctx) = ctx_app(None);
let op = SchemaOperation::AddColumn {
table: "users".to_string(),
column: generated_column("email_lower", "LOWER(email)"),
};
assert_eq!(
classify_operation(&op, &ctx),
OnlineSafetyClassification::OfflineOnly,
);
}
#[test]
fn alter_column_set_generated_classifies_as_offline_only() {
let (_unused, ctx) = ctx_app(Some(50));
let op = SchemaOperation::AlterColumn {
table: "users".to_string(),
column: "email_lower".to_string(),
change: ColumnChange::SetGenerated {
from: None,
to: Some(GeneratedColumnSchema {
expression: "LOWER(email)".to_string(),
stored: true,
}),
},
};
assert_eq!(
classify_operation(&op, &ctx),
OnlineSafetyClassification::OfflineOnly,
);
}
#[test]
fn alter_column_drop_generated_also_classifies_as_offline_only() {
let (_unused, ctx) = ctx_app(Some(50));
let op = SchemaOperation::AlterColumn {
table: "users".to_string(),
column: "email_lower".to_string(),
change: ColumnChange::SetGenerated {
from: Some(GeneratedColumnSchema {
expression: "LOWER(email)".to_string(),
stored: true,
}),
to: None,
},
};
assert_eq!(
classify_operation(&op, &ctx),
OnlineSafetyClassification::OfflineOnly,
);
}
}