use std::collections::BTreeSet;
use std::fmt::Write as _;
use super::diff::{
Classification, ColumnChange, EnumVariantAnchor, EnumVariantAnchorKind, SchemaDelta,
SchemaOperation,
};
use super::projection::BucketKey;
use super::schema::{
ColumnSchema, EnumSchema, ExclusionConstraintSchema, ForeignKeySchema, IndexColumnSchema,
IndexKindSchema, IndexNullsOrderSchema, IndexOrderSchema, IndexSchema, IndexTargetSchema,
IndexTypeSchema, OnDeleteSchema, PartitionSchema, PkKindSchema, TableSchema,
};
#[cfg(test)]
use super::schema::{
NamedNotNullConstraintSchema, PeriodForeignKeyConstraintSchema,
TemporalPrimaryKeyConstraintSchema,
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OperationSql {
pub label: String,
pub up: String,
pub down: String,
pub lossy: Option<LossyRollbackWarning>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LossyRollbackWarning {
pub kind: LossyRollbackKind,
pub detail: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LossyRollbackKind {
DropColumn,
DropTable,
DropEnum,
DropIndex,
PkTypeFlipPostCutover,
CustomCast,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SqlEmitError {
Unsupported {
reason: String,
},
PkTypeFlipMustRouteToT9 {
table: String,
from: PkKindSchema,
to: PkKindSchema,
},
UnsupportedPartitionChange {
table: String,
detail: String,
},
InvalidStorageParams {
params: String,
reason: String,
},
Diff(super::diff::DiffError),
}
impl std::fmt::Display for SqlEmitError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SqlEmitError::Unsupported { reason } => {
write!(f, "migration cannot be lowered automatically: {reason}")
}
SqlEmitError::PkTypeFlipMustRouteToT9 { table, from, to } => write!(
f,
"table `{table}`: PK-type flip ({from:?} -> {to:?}) reached the standard \
SQL emitter — these are orchestrated by the PK-flip expand/contract \
playbook and must never go through the standard path"
),
SqlEmitError::UnsupportedPartitionChange { table, detail } => write!(
f,
"table `{table}`: partition shape change cannot be lowered automatically: \
{detail}"
),
SqlEmitError::InvalidStorageParams { params, reason } => {
write!(f, "invalid storage_params `{params}`: {reason}")
}
SqlEmitError::Diff(err) => write!(f, "{err}"),
}
}
}
impl std::error::Error for SqlEmitError {}
#[allow(clippy::result_large_err)]
pub fn lower_delta(delta: &SchemaDelta) -> Result<Vec<OperationSql>, SqlEmitError> {
if matches!(delta.classification, Classification::NoOp) {
return Ok(Vec::new());
}
let mut out = Vec::with_capacity(delta.operations.len());
for op in &delta.operations {
out.push(lower_operation(op)?);
}
Ok(out)
}
#[allow(clippy::result_large_err)]
pub(crate) fn lower_operation(op: &SchemaOperation) -> Result<OperationSql, SqlEmitError> {
match op {
SchemaOperation::AddTable(t) => try_emit_add_table(t),
SchemaOperation::DropTable(name) => Ok(emit_drop_table(name)),
SchemaOperation::RenameTable { from, to } => Ok(emit_rename_table(from, to)),
SchemaOperation::AddColumn { table, column } => Ok(emit_add_column(table, column)),
SchemaOperation::DropColumn { table, column } => Ok(emit_drop_column(table, column)),
SchemaOperation::RenameColumn { table, from, to } => {
Ok(emit_rename_column(table, from, to))
}
SchemaOperation::AlterColumn {
table,
column,
change,
} => Ok(emit_alter_column(table, column, change)),
SchemaOperation::AddForeignKey { table, column, fk } => {
Ok(emit_add_foreign_key(table, column, fk))
}
SchemaOperation::DropForeignKey { table, column, fk } => {
Ok(emit_drop_foreign_key(table, column, fk))
}
SchemaOperation::AddIndex(idx) => Ok(emit_add_index(idx)),
SchemaOperation::DropIndex(idx) => Ok(emit_drop_index(idx)),
SchemaOperation::AddExclusionConstraint { table, exclusion } => {
Ok(emit_add_exclusion_constraint(table, exclusion))
}
SchemaOperation::DropExclusionConstraint {
table,
name,
exclusion,
} => Ok(emit_drop_exclusion_constraint(table, name, exclusion)),
SchemaOperation::AddEnum(e) => Ok(emit_add_enum(e)),
SchemaOperation::DropEnum(name) => Ok(emit_drop_enum(name)),
SchemaOperation::AddEnumVariant {
enum_name,
variant,
anchor,
} => Ok(emit_add_enum_variant(enum_name, variant, anchor.as_ref())),
SchemaOperation::SetTableComment { table, from, to } => Ok(emit_set_table_comment(
table,
from.as_deref(),
to.as_deref(),
)),
SchemaOperation::SetStorageParams { table, from, to } => {
emit_set_storage_params(table, from.as_deref(), to.as_deref())
}
SchemaOperation::SetTablespace { table, from, to } => {
Ok(emit_set_tablespace(table, from.as_deref(), to.as_deref()))
}
SchemaOperation::PkTypeFlip { table, from, to } => {
Err(SqlEmitError::PkTypeFlipMustRouteToT9 {
table: table.clone(),
from: from.clone(),
to: to.clone(),
})
}
SchemaOperation::PkTypeFlipGroup(group) => {
let mut summary = String::new();
let _ = std::fmt::Write::write_fmt(
&mut summary,
format_args!(
"-- PkTypeFlipGroup parent={parent} {from:?} -> {to:?}\n\
-- children={children}, self_fk={self_fk}, join_tables={join},\n\
-- cycles={cycles}, partitioned={partitioned}.\n\
-- See the segment plan for the executable SQL (preparation,\n\
-- backfill, concurrent index, NOT NULL proof, cutover).",
parent = group.parent_table,
from = group.parent_from,
to = group.parent_to,
children = group.children.len(),
self_fk = group.self_fk.is_some(),
join = group.join_tables.len(),
cycles = group.cycles.len(),
partitioned = group.partitioned_parent.is_some(),
),
);
Ok(OperationSql {
label: format!("PkTypeFlipGroup {}", group.parent_table),
up: summary,
down: format!(
"-- PkTypeFlipGroup parent={} — see cutover segment for the\n\
-- POINT OF NO RETURN marker; rollback requires an inverse\n\
-- migration.",
group.parent_table,
),
lossy: Some(LossyRollbackWarning {
kind: LossyRollbackKind::PkTypeFlipPostCutover,
detail: format!(
"PkTypeFlipGroup `{}` cutover removes the prior PK column \
and trigger; rollback requires an inverse migration",
group.parent_table,
),
}),
})
}
SchemaOperation::PkTypeFlipMultiGroup(groups) => {
let mut summary = String::new();
let _ = std::fmt::Write::write_fmt(
&mut summary,
format_args!(
"-- PkTypeFlipMultiGroup parents={count}\n\
-- See the stage-interleaved segment plan for the executable SQL\n\
-- (one prep / backfill / index / FK / NOT NULL / cutover\n\
-- segment touching every cluster member at each stage, per\n\
-- HeeRanjID asc-to-desc playbook §7).\n",
count = groups.len(),
),
);
for g in groups {
let _ = std::fmt::Write::write_fmt(
&mut summary,
format_args!(
"-- member parent={parent} {from:?} -> {to:?} children={children},\n\
-- self_fk={self_fk}, join_tables={join}, cycles={cycles},\n\
-- partitioned={partitioned}.\n",
parent = g.parent_table,
from = g.parent_from,
to = g.parent_to,
children = g.children.len(),
self_fk = g.self_fk.is_some(),
join = g.join_tables.len(),
cycles = g.cycles.len(),
partitioned = g.partitioned_parent.is_some(),
),
);
}
let mut label = String::from("PkTypeFlipMultiGroup ");
let names: Vec<&str> = groups.iter().map(|g| g.parent_table.as_str()).collect();
label.push_str(&names.join(","));
let detail = format!(
"PkTypeFlipMultiGroup [{names}] cutover removes prior PK columns and \
triggers across every cluster member; rollback requires an inverse \
migration",
names = names.join(","),
);
Ok(OperationSql {
label,
up: summary,
down: format!(
"-- PkTypeFlipMultiGroup [{names}] — see cutover segment for the\n\
-- POINT OF NO RETURN marker; rollback requires an inverse\n\
-- migration.",
names = names.join(","),
),
lossy: Some(LossyRollbackWarning {
kind: LossyRollbackKind::PkTypeFlipPostCutover,
detail,
}),
})
}
SchemaOperation::RenameApp { from, to } => Ok(emit_rename_app(from, to)),
SchemaOperation::MoveModelBetweenApps {
model,
from_app,
to_app,
} => Ok(emit_move_model_between_apps(model, from_app, to_app)),
SchemaOperation::Unsupported { reason } => Err(SqlEmitError::Unsupported {
reason: reason.clone(),
}),
}
}
pub type SqlBucket = BucketKey;
#[cfg(test)]
fn emit_add_table(t: &TableSchema) -> OperationSql {
try_emit_add_table(t).expect("test fixture storage_params should be valid")
}
#[allow(clippy::result_large_err)]
fn try_emit_add_table(t: &TableSchema) -> Result<OperationSql, SqlEmitError> {
let qt = quote_ident(&t.table);
let mut up = String::with_capacity(256);
let _ = writeln!(up, "CREATE TABLE {qt} (");
let mut first = true;
for col in &t.columns {
if !first {
up.push_str(",\n");
}
first = false;
up.push_str(" ");
write_column_definition(&mut up, col, &t.table);
}
if let Some(pk_clause) = pk_table_clause(t) {
up.push_str(",\n ");
up.push_str(&pk_clause);
}
for exclusion in &t.exclusion_constraints {
up.push_str(",\n CONSTRAINT ");
up.push_str("e_ident(&exclusion.name));
up.push(' ');
up.push_str(&render_exclusion_body(exclusion));
}
up.push('\n');
up.push(')');
if let Some(part) = &t.partition {
up.push(' ');
up.push_str(&partition_clause(part));
}
up.push(';');
if let Some(comment) = t.table_comment.as_deref() {
up.push('\n');
let _ = write!(
up,
"COMMENT ON TABLE {qt} IS {};",
render_comment_literal(comment)
);
}
for col in &t.columns {
if let Some(comment) = col.comment.as_deref() {
up.push('\n');
up.push_str(&render_comment_on_column(
&t.table,
&col.name,
Some(comment),
));
}
}
if let Some(params) = t.storage_params.as_deref() {
up.push('\n');
up.push_str(&render_set_storage_params(&t.table, params)?);
}
if let Some(tablespace) = t.tablespace.as_deref() {
up.push('\n');
up.push_str(&render_set_tablespace(&t.table, Some(tablespace)));
}
let down = format!("DROP TABLE {qt};");
Ok(OperationSql {
label: format!("AddTable {}", t.table),
up,
down,
lossy: None,
})
}
fn emit_drop_table(name: &str) -> OperationSql {
let qn = quote_ident(name);
let up = format!("DROP TABLE {qn};");
let down = format!(
"-- LOSSY ROLLBACK: cannot recreate table `{name}` from the diff.\n\
-- The migration emitter has no historical schema for the dropped\n\
-- table; rollback must be hand-written if needed."
);
OperationSql {
label: format!("DropTable {name}"),
up,
down,
lossy: Some(LossyRollbackWarning {
kind: LossyRollbackKind::DropTable,
detail: format!(
"table `{name}` dropped — schema and rows are lost; rollback \
has no recreate path"
),
}),
}
}
fn emit_rename_table(from: &str, to: &str) -> OperationSql {
let qf = quote_ident(from);
let qt = quote_ident(to);
OperationSql {
label: format!("RenameTable {from} -> {to}"),
up: format!("ALTER TABLE {qf} RENAME TO {qt};"),
down: format!("ALTER TABLE {qt} RENAME TO {qf};"),
lossy: None,
}
}
fn emit_add_column(table: &str, col: &ColumnSchema) -> OperationSql {
let qt = quote_ident(table);
let qc = quote_ident(&col.name);
let mut up = String::with_capacity(128);
let _ = write!(up, "ALTER TABLE {qt} ADD COLUMN ");
write_column_definition(&mut up, col, table);
up.push(';');
if let Some(comment) = col.comment.as_deref() {
up.push('\n');
up.push_str(&render_comment_on_column(table, &col.name, Some(comment)));
}
let down = format!("ALTER TABLE {qt} DROP COLUMN {qc};");
OperationSql {
label: format!("AddColumn {table}.{}", col.name),
up,
down,
lossy: Some(LossyRollbackWarning {
kind: LossyRollbackKind::DropColumn,
detail: format!(
"column `{}.{}` dropped on rollback — any values written between \
apply and rollback are lost",
table, col.name
),
}),
}
}
fn emit_drop_column(table: &str, column: &str) -> OperationSql {
let qt = quote_ident(table);
let qc = quote_ident(column);
let up = format!("ALTER TABLE {qt} DROP COLUMN {qc};");
let down = format!(
"-- LOSSY ROLLBACK: cannot rebuild column `{table}.{column}` from the diff.\n\
-- The migration emitter has no original `ColumnSchema` for the dropped\n\
-- column; rollback must be hand-written if needed."
);
OperationSql {
label: format!("DropColumn {table}.{column}"),
up,
down,
lossy: Some(LossyRollbackWarning {
kind: LossyRollbackKind::DropColumn,
detail: format!(
"column `{table}.{column}` dropped — original type and row data \
not recoverable from the diff"
),
}),
}
}
fn emit_rename_column(table: &str, from: &str, to: &str) -> OperationSql {
let qt = quote_ident(table);
let qf = quote_ident(from);
let qto = quote_ident(to);
OperationSql {
label: format!("RenameColumn {table}.{from} -> {to}"),
up: format!("ALTER TABLE {qt} RENAME COLUMN {qf} TO {qto};"),
down: format!("ALTER TABLE {qt} RENAME COLUMN {qto} TO {qf};"),
lossy: None,
}
}
fn emit_alter_column(table: &str, column: &str, change: &ColumnChange) -> OperationSql {
let qt = quote_ident(table);
let qc = quote_ident(column);
let (up, down, label_suffix, lossy) = match change {
ColumnChange::SetNullable(true) => (
format!("ALTER TABLE {qt} ALTER COLUMN {qc} DROP NOT NULL;"),
format!("ALTER TABLE {qt} ALTER COLUMN {qc} SET NOT NULL;"),
"drop NOT NULL",
None,
),
ColumnChange::SetNullable(false) => (
format!("ALTER TABLE {qt} ALTER COLUMN {qc} SET NOT NULL;"),
format!("ALTER TABLE {qt} ALTER COLUMN {qc} DROP NOT NULL;"),
"set NOT NULL",
None,
),
ColumnChange::SetDefault(Some(expr)) => (
format!("ALTER TABLE {qt} ALTER COLUMN {qc} SET DEFAULT {expr};"),
format!(
"-- NOTE: prior DEFAULT for `{table}.{column}` not recoverable from the diff.\n\
ALTER TABLE {qt} ALTER COLUMN {qc} DROP DEFAULT;"
),
"set DEFAULT",
None,
),
ColumnChange::SetDefault(None) => (
format!("ALTER TABLE {qt} ALTER COLUMN {qc} DROP DEFAULT;"),
format!(
"-- NOTE: prior DEFAULT for `{table}.{column}` not recoverable from the diff.\n\
-- Rollback would need the original DEFAULT expression."
),
"drop DEFAULT",
None,
),
ColumnChange::ChangeType { from, to, using } => {
let mut up = String::new();
if using.is_none() && type_change_likely_requires_using(from, to) {
let _ = writeln!(
up,
"-- WARNING: `{table}.{column}` `{from}` -> `{to}` is a known incompatible cast pair.\n\
-- The framework emits `USING <col>::<new_type>` (the explicit cast) — every row's\n\
-- value must parse as a syntactically valid `{to}` literal under `{from}::{to}` or\n\
-- the apply will fail with `invalid input syntax for type {to}` (or similar). Add\n\
-- `#[field(type_change_using = \"<sql expr>\")]` to the field on the model struct\n\
-- and re-compose to inline a custom `USING` clause that handles edge cases (TRIM,\n\
-- CASE WHEN, validation). The adopter owns the expression's correctness against\n\
-- the column's old and new types. See `docs/guide/models.md` `#[field(...)]`\n\
-- reference and djogi#220 for details."
);
}
match using {
Some(expr) => {
let _ = write!(
up,
"ALTER TABLE {qt} ALTER COLUMN {qc} TYPE {to} USING ({expr});"
);
}
None => {
let _ = write!(
up,
"ALTER TABLE {qt} ALTER COLUMN {qc} TYPE {to} USING {qc}::{to};"
);
}
}
let down =
format!("ALTER TABLE {qt} ALTER COLUMN {qc} TYPE {from} USING {qc}::{from};");
let lossy = using.as_ref().map(|expr| LossyRollbackWarning {
kind: LossyRollbackKind::CustomCast,
detail: format!(
"column `{table}.{column}`: forward `USING ({expr})` is \
adopter-supplied; rollback emits the default `<col>::{from}` \
cast and may not reconstruct original row data. Hand-edit the \
emitted down SQL if a non-default inverse is required."
),
});
(up, down, "change TYPE", lossy)
}
ColumnChange::SetCheck { from, to } => {
let constraint = check_constraint_name(table, column);
let qcons = quote_ident(&constraint);
let (up, down, label) = match (from, to) {
(Some(prior), None) => {
(
format!("ALTER TABLE {qt} DROP CONSTRAINT {qcons};"),
format!("ALTER TABLE {qt} ADD CONSTRAINT {qcons} CHECK ({prior});"),
"drop CHECK",
)
}
(None, Some(expr)) => {
(
format!("ALTER TABLE {qt} ADD CONSTRAINT {qcons} CHECK ({expr});"),
format!("ALTER TABLE {qt} DROP CONSTRAINT {qcons};"),
"set CHECK",
)
}
(Some(prior), Some(expr)) => {
(
format!(
"ALTER TABLE {qt} DROP CONSTRAINT {qcons};\n\
ALTER TABLE {qt} ADD CONSTRAINT {qcons} CHECK ({expr});"
),
format!(
"ALTER TABLE {qt} DROP CONSTRAINT {qcons};\n\
ALTER TABLE {qt} ADD CONSTRAINT {qcons} CHECK ({prior});"
),
"amend CHECK",
)
}
(None, None) => {
(
format!(
"-- noop SetCheck on `{table}.{column}` (from == to == None); \
likely a differ bug.\n"
),
format!("-- noop SetCheck rollback on `{table}.{column}`.\n"),
"noop CHECK",
)
}
};
(up, down, label, None)
}
ColumnChange::SetUnique(true) => {
let constraint = unique_constraint_name(table, column);
let qcons = quote_ident(&constraint);
let up = format!("ALTER TABLE {qt} ADD CONSTRAINT {qcons} UNIQUE ({qc});");
let down = format!("ALTER TABLE {qt} DROP CONSTRAINT {qcons};");
(up, down, "set UNIQUE", None)
}
ColumnChange::SetUnique(false) => {
let constraint = unique_constraint_name(table, column);
let qcons = quote_ident(&constraint);
let up = format!("ALTER TABLE {qt} DROP CONSTRAINT {qcons};");
let down = format!("ALTER TABLE {qt} ADD CONSTRAINT {qcons} UNIQUE ({qc});");
(up, down, "drop UNIQUE", None)
}
ColumnChange::SetIndexed(true) => {
let name = crate::descriptor::index_name(
table,
crate::descriptor::IndexNameKind::NonUnique,
crate::descriptor::IndexNameTarget::Columns(&[column]),
);
let qname = quote_ident(&name);
let up = format!("CREATE INDEX {qname} ON {qt} ({qc});");
let down = format!("DROP INDEX {qname};");
(up, down, "set indexed", None)
}
ColumnChange::SetIndexed(false) => {
let name = crate::descriptor::index_name(
table,
crate::descriptor::IndexNameKind::NonUnique,
crate::descriptor::IndexNameTarget::Columns(&[column]),
);
let qname = quote_ident(&name);
let up = format!("DROP INDEX {qname};");
let down = format!("CREATE INDEX {qname} ON {qt} ({qc});");
(up, down, "drop indexed", None)
}
ColumnChange::SetGenerated { from, to } => match (from, to) {
(None, Some(spec)) => {
let up = format!(
"-- OfflineOnly: adding a stored generated expression to existing column\n\
-- `{table}.{column}` has no online ALTER form in Postgres 18. Hand-edit\n\
-- the migration to `DROP COLUMN {column}` + `ADD COLUMN {column} \
<type> GENERATED ALWAYS AS ({expr}) STORED`.\n\
-- See `docs/spec/decisions.md` and the `generated_column_refusal`\n\
-- pattern in `djogi/src/live_migrate/patterns/`.",
expr = spec.expression
);
let down = format!(
"-- OfflineOnly: revert of adding generation requires dropping the\n\
-- generated column entirely (`DROP COLUMN {column}`); hand-edit\n\
-- the migration before applying."
);
(up, down, "add GENERATED expression", None)
}
(Some(prev), None) => {
let up = format!("ALTER TABLE {qt} ALTER COLUMN {qc} DROP EXPRESSION;");
let down = format!(
"-- LOSSY ROLLBACK: cannot re-add the generation expression in place\n\
-- on `{table}.{column}` — `ALTER COLUMN ... SET EXPRESSION AS` requires\n\
-- the column to already be a generated column. Restoring requires a\n\
-- `DROP COLUMN {column}` + `ADD COLUMN {column} <type> GENERATED ALWAYS\n\
-- AS ({expr}) STORED` pair, which destroys the post-DROP-EXPRESSION\n\
-- row data. Hand-edit before applying.",
expr = prev.expression
);
(
up,
down,
"drop GENERATED expression",
Some(LossyRollbackWarning {
kind: LossyRollbackKind::DropColumn,
detail: format!(
"column `{table}.{column}`: rollback of `DROP EXPRESSION` \
cannot re-install the generated expression in place; \
restoring requires DROP COLUMN + ADD COLUMN"
),
}),
)
}
(Some(prev), Some(next)) => {
debug_assert_ne!(
prev.expression, next.expression,
"ColumnChange::SetGenerated should not be emitted for identical \
expressions (differ contract drift)"
);
let up = format!(
"-- djogi#221: in-place stored generated expression change on PG 17+\n\
-- (djogi targets PG 18+). The runner rewrites every row under\n\
-- AccessExclusiveLock — classified OfflineOnly even though the\n\
-- statement is structurally a single ALTER COLUMN.\n\
ALTER TABLE {qt} ALTER COLUMN {qc} SET EXPRESSION AS ({next_expr});",
next_expr = next.expression
);
let down = format!(
"ALTER TABLE {qt} ALTER COLUMN {qc} SET EXPRESSION AS ({prev_expr});",
prev_expr = prev.expression
);
(up, down, "change GENERATED expression", None)
}
(None, None) => unreachable!(
"ColumnChange::SetGenerated should never carry (None, None) — \
the differ filters no-op transitions"
),
},
ColumnChange::SetIdentity { from, to } => {
let qt = quote_ident(table);
let qc = quote_ident(column);
match (from, to) {
(None, Some(kind)) => {
let clause = kind.sql_clause();
let up = format!(
"ALTER TABLE {qt} ALTER COLUMN {qc} ADD {clause};\n\
SELECT setval(pg_get_serial_sequence('{table}', '{column}'), \
GREATEST(COALESCE((SELECT MAX({qc}) FROM {qt}), 0), 0) + 1, false);"
);
let down = format!("ALTER TABLE {qt} ALTER COLUMN {qc} DROP IDENTITY;");
(up, down, "add IDENTITY", None)
}
(Some(prev), None) => {
let clause = prev.sql_clause();
let up = format!("ALTER TABLE {qt} ALTER COLUMN {qc} DROP IDENTITY;");
let down = format!(
"ALTER TABLE {qt} ALTER COLUMN {qc} ADD {clause};\n\
SELECT setval(pg_get_serial_sequence('{table}', '{column}'), \
GREATEST(COALESCE((SELECT MAX({qc}) FROM {qt}), 0), 0) + 1, false);"
);
(up, down, "drop IDENTITY", None)
}
(Some(prev), Some(next)) => {
let next_clause = next.sql_clause();
let prev_clause = prev.sql_clause();
let next_kind_only = next_clause
.strip_prefix("GENERATED ")
.and_then(|s| s.split(" AS ").next())
.unwrap_or(next_clause);
let prev_kind_only = prev_clause
.strip_prefix("GENERATED ")
.and_then(|s| s.split(" AS ").next())
.unwrap_or(prev_clause);
let up = format!(
"ALTER TABLE {qt} ALTER COLUMN {qc} SET GENERATED {next_kind_only};"
);
let down = format!(
"ALTER TABLE {qt} ALTER COLUMN {qc} SET GENERATED {prev_kind_only};"
);
(up, down, "kind IDENTITY", None)
}
(None, None) => {
unreachable!("ColumnChange::SetIdentity is only emitted when from != to")
}
}
}
ColumnChange::SetComment { from, to } => {
let up = render_comment_on_column(table, column, to.as_deref());
let down = render_comment_on_column(table, column, from.as_deref());
(up, down, "set COMMENT", None)
}
ColumnChange::CodecChange {
from_codec,
to_codec,
} => {
let from_desc = from_codec.as_deref().unwrap_or("plaintext");
let to_desc = to_codec.as_deref().unwrap_or("plaintext");
let up = format!(
"-- MANUAL STEP: column `{table}.{column}` codec changed \
`{from_desc}` -> `{to_desc}`. Re-encode every row out of band \
(read, re-encrypt under the new codec, write back). The framework \
emits no automatic backfill — online codec rotation is not yet \
supported (issue #371). Apply via an operator-run offline migration."
);
let down = format!(
"-- MANUAL STEP: reverting column `{table}.{column}` codec \
`{to_desc}` -> `{from_desc}` requires the same out-of-band re-encode \
in reverse. No automatic rollback is emitted."
);
(up, down, "codec change (manual re-encode)", None)
}
};
OperationSql {
label: format!("AlterColumn {table}.{column} ({label_suffix})"),
up,
down,
lossy,
}
}
fn emit_add_foreign_key(table: &str, column: &str, fk: &ForeignKeySchema) -> OperationSql {
let up = render_add_fk(table, column, fk);
let constraint = fk_constraint_name(table, column);
let qcons = quote_ident(&constraint);
let qt = quote_ident(table);
let down = format!("ALTER TABLE {qt} DROP CONSTRAINT {qcons};");
OperationSql {
label: format!("AddForeignKey {table}.{column}"),
up,
down,
lossy: None,
}
}
fn emit_drop_foreign_key(table: &str, column: &str, fk: &ForeignKeySchema) -> OperationSql {
let constraint = fk_constraint_name(table, column);
let qcons = quote_ident(&constraint);
let qt = quote_ident(table);
let up = format!("ALTER TABLE {qt} DROP CONSTRAINT {qcons};");
let down = render_add_fk(table, column, fk);
OperationSql {
label: format!("DropForeignKey {table}.{column}"),
up,
down,
lossy: None,
}
}
fn render_add_fk(table: &str, column: &str, fk: &ForeignKeySchema) -> String {
let constraint = fk_constraint_name(table, column);
let qcons = quote_ident(&constraint);
let qt = quote_ident(table);
let qc = quote_ident(column);
let qref_t = quote_ident(&fk.ref_table);
let qref_c = quote_ident(&fk.ref_column);
let cascade = on_delete_sql(fk.on_delete);
let deferrable = render_deferrable_clause(fk.deferrable, fk.initially_deferred);
format!(
"ALTER TABLE {qt} ADD CONSTRAINT {qcons} \
FOREIGN KEY ({qc}) REFERENCES {qref_t} ({qref_c}) \
ON DELETE {cascade}{deferrable};"
)
}
fn render_deferrable_clause(deferrable: bool, initially_deferred: bool) -> &'static str {
if !deferrable {
""
} else if initially_deferred {
" DEFERRABLE INITIALLY DEFERRED"
} else {
" DEFERRABLE INITIALLY IMMEDIATE"
}
}
fn emit_add_index(idx: &IndexSchema) -> OperationSql {
if idx.kind == IndexKindSchema::UniqueConstraint {
let qt = quote_ident(&idx.table);
let qname = quote_ident(&idx.name);
let mut cols = String::new();
write_constraint_column_list(&mut cols, &idx.target);
let up = format!("ALTER TABLE {qt} ADD CONSTRAINT {qname} UNIQUE {cols};");
let down = format!("ALTER TABLE {qt} DROP CONSTRAINT {qname};");
return OperationSql {
label: format!("AddConstraintUnique {}", idx.name),
up,
down,
lossy: None,
};
}
if idx.kind == IndexKindSchema::UniqueIndex && idx.index_type != IndexTypeSchema::BTree {
panic!(
"emit_add_index: PostgreSQL unique indexes are btree-only, but \
UniqueIndex {name:?} on {table:?} carries index_type {ty:?}. \
The macro layer rejects this combination at compile time; an \
IndexSchema reaching this emitter with a non-btree UniqueIndex \
indicates either a stale snapshot or a direct IndexSchema \
construction bypassing the rule.",
name = idx.name,
table = idx.table,
ty = idx.index_type,
);
}
let mut up = String::with_capacity(128);
let create = match idx.kind {
IndexKindSchema::NonUnique => "CREATE INDEX",
IndexKindSchema::UniqueIndex => "CREATE UNIQUE INDEX",
IndexKindSchema::UniqueConstraint => unreachable!("UniqueConstraint handled above"),
};
let _ = write!(up, "{create}");
if idx.requires_out_of_transaction {
up.push_str(" CONCURRENTLY");
}
let qname = quote_ident(&idx.name);
let qtable = quote_ident(&idx.table);
let _ = write!(
up,
" {qname} ON {qtable} USING {}",
index_method(idx.index_type)
);
up.push(' ');
write_index_target(&mut up, &idx.target);
if !idx.include.is_empty() {
up.push_str(" INCLUDE (");
let mut first = true;
for col in &idx.include {
if !first {
up.push_str(", ");
}
first = false;
up.push_str("e_ident(col));
}
up.push(')');
}
if idx.nulls_not_distinct {
up.push_str(" NULLS NOT DISTINCT");
}
if let Some(pred) = &idx.predicate {
let _ = write!(up, " WHERE {pred}");
}
up.push(';');
let mut down = String::with_capacity(64);
down.push_str("DROP INDEX");
if idx.requires_out_of_transaction {
down.push_str(" CONCURRENTLY");
}
let _ = write!(down, " {qname};");
OperationSql {
label: format!("AddIndex {}", idx.name),
up,
down,
lossy: None,
}
}
fn emit_drop_index(idx: &IndexSchema) -> OperationSql {
if idx.kind == IndexKindSchema::UniqueConstraint {
let qt = quote_ident(&idx.table);
let qname = quote_ident(&idx.name);
let up = format!("ALTER TABLE {qt} DROP CONSTRAINT {qname};");
let down = recreate_index_sql(idx);
return OperationSql {
label: format!("DropConstraintUnique {}", idx.name),
up,
down,
lossy: Some(LossyRollbackWarning {
kind: LossyRollbackKind::DropIndex,
detail: format!(
"UNIQUE constraint `{}` dropped — rollback recreates the constraint, \
which requires a full table scan to validate uniqueness",
idx.name
),
}),
};
}
let qname = quote_ident(&idx.name);
let mut up = String::with_capacity(64);
up.push_str("DROP INDEX");
if idx.requires_out_of_transaction {
up.push_str(" CONCURRENTLY");
}
let _ = write!(up, " {qname};");
let down = recreate_index_sql(idx);
OperationSql {
label: format!("DropIndex {}", idx.name),
up,
down,
lossy: Some(LossyRollbackWarning {
kind: LossyRollbackKind::DropIndex,
detail: format!(
"index `{}` dropped — rollback rebuilds the index, which may \
take significant time on large tables",
idx.name
),
}),
}
}
fn render_exclusion_body(exclusion: &ExclusionConstraintSchema) -> String {
let mut body = String::with_capacity(64);
let _ = write!(body, "EXCLUDE USING {} (", exclusion.using);
for (idx, elem) in exclusion.elements.iter().enumerate() {
if idx > 0 {
body.push_str(", ");
}
let _ = write!(body, "{} WITH {}", elem.expr, elem.with_operator);
}
body.push(')');
if let Some(where_clause) = &exclusion.where_clause {
let _ = write!(body, " WHERE ({where_clause})");
}
if exclusion.deferrable {
body.push_str(" DEFERRABLE");
if exclusion.initially_deferred {
body.push_str(" INITIALLY DEFERRED");
}
}
body
}
fn emit_add_exclusion_constraint(
table: &str,
exclusion: &ExclusionConstraintSchema,
) -> OperationSql {
let qt = quote_ident(table);
let qname = quote_ident(&exclusion.name);
let body = render_exclusion_body(exclusion);
let up = format!("ALTER TABLE {qt} ADD CONSTRAINT {qname} {body};");
let down = format!("ALTER TABLE {qt} DROP CONSTRAINT {qname};");
OperationSql {
label: format!("AddExclusionConstraint {table}.{}", exclusion.name),
up,
down,
lossy: None,
}
}
fn emit_drop_exclusion_constraint(
table: &str,
name: &str,
exclusion: &ExclusionConstraintSchema,
) -> OperationSql {
let qt = quote_ident(table);
let qname = quote_ident(name);
let up = format!("ALTER TABLE {qt} DROP CONSTRAINT {qname};");
let body = render_exclusion_body(exclusion);
let down = format!("ALTER TABLE {qt} ADD CONSTRAINT {qname} {body};");
OperationSql {
label: format!("DropExclusionConstraint {table}.{name}"),
up,
down,
lossy: None,
}
}
#[cfg(test)]
fn emit_add_named_not_null_constraint(
table: &str,
constraint: &NamedNotNullConstraintSchema,
) -> OperationSql {
let qt = quote_ident(table);
let qname = quote_ident(&constraint.name);
let qcol = quote_ident(&constraint.column);
let up = format!("ALTER TABLE {qt} ADD CONSTRAINT {qname} NOT NULL {qcol};");
let down = format!("ALTER TABLE {qt} DROP CONSTRAINT {qname};");
OperationSql {
label: format!("AddNamedNotNullConstraint {table}.{}", constraint.name),
up,
down,
lossy: None,
}
}
#[cfg(test)]
fn emit_drop_named_not_null_constraint(
table: &str,
constraint: &NamedNotNullConstraintSchema,
) -> OperationSql {
let qt = quote_ident(table);
let qname = quote_ident(&constraint.name);
let qcol = quote_ident(&constraint.column);
let up = format!("ALTER TABLE {qt} DROP CONSTRAINT {qname};");
let down = format!("ALTER TABLE {qt} ADD CONSTRAINT {qname} NOT NULL {qcol};");
OperationSql {
label: format!("DropNamedNotNullConstraint {table}.{}", constraint.name),
up,
down,
lossy: None,
}
}
#[cfg(test)]
fn emit_add_temporal_primary_key_constraint(
table: &str,
constraint: &TemporalPrimaryKeyConstraintSchema,
) -> OperationSql {
let qt = quote_ident(table);
let qname = quote_ident(&constraint.name);
let body = render_temporal_primary_key_body(constraint);
let up = format!("ALTER TABLE {qt} ADD CONSTRAINT {qname} {body};");
let down = format!("ALTER TABLE {qt} DROP CONSTRAINT {qname};");
OperationSql {
label: format!(
"AddTemporalPrimaryKeyConstraint {table}.{}",
constraint.name
),
up,
down,
lossy: None,
}
}
#[cfg(test)]
fn emit_drop_temporal_primary_key_constraint(
table: &str,
constraint: &TemporalPrimaryKeyConstraintSchema,
) -> OperationSql {
let qt = quote_ident(table);
let qname = quote_ident(&constraint.name);
let body = render_temporal_primary_key_body(constraint);
let up = format!("ALTER TABLE {qt} DROP CONSTRAINT {qname};");
let down = format!("ALTER TABLE {qt} ADD CONSTRAINT {qname} {body};");
OperationSql {
label: format!(
"DropTemporalPrimaryKeyConstraint {table}.{}",
constraint.name
),
up,
down,
lossy: None,
}
}
#[cfg(test)]
fn emit_add_period_foreign_key_constraint(
table: &str,
constraint: &PeriodForeignKeyConstraintSchema,
) -> OperationSql {
let qt = quote_ident(table);
let qname = quote_ident(&constraint.name);
let body = render_period_foreign_key_body(constraint);
let up = format!("ALTER TABLE {qt} ADD CONSTRAINT {qname} {body};");
let down = format!("ALTER TABLE {qt} DROP CONSTRAINT {qname};");
OperationSql {
label: format!("AddPeriodForeignKeyConstraint {table}.{}", constraint.name),
up,
down,
lossy: None,
}
}
#[cfg(test)]
fn emit_drop_period_foreign_key_constraint(
table: &str,
constraint: &PeriodForeignKeyConstraintSchema,
) -> OperationSql {
let qt = quote_ident(table);
let qname = quote_ident(&constraint.name);
let body = render_period_foreign_key_body(constraint);
let up = format!("ALTER TABLE {qt} DROP CONSTRAINT {qname};");
let down = format!("ALTER TABLE {qt} ADD CONSTRAINT {qname} {body};");
OperationSql {
label: format!("DropPeriodForeignKeyConstraint {table}.{}", constraint.name),
up,
down,
lossy: None,
}
}
fn emit_add_enum(e: &EnumSchema) -> OperationSql {
let qname = quote_ident(&e.name);
let mut up = String::with_capacity(64);
let _ = write!(up, "CREATE TYPE {qname} AS ENUM (");
let mut first = true;
for v in &e.variants {
if !first {
up.push_str(", ");
}
first = false;
up.push_str("e_string_literal(v));
}
up.push_str(");");
let down = format!("DROP TYPE {qname};");
OperationSql {
label: format!("AddEnum {}", e.name),
up,
down,
lossy: None,
}
}
fn emit_drop_enum(name: &str) -> OperationSql {
let qname = quote_ident(name);
let up = format!("DROP TYPE {qname};");
let down = format!(
"-- LOSSY ROLLBACK: cannot reconstruct enum `{name}` from the diff.\n\
-- The original variant list is not present in DropEnum's payload.\n\
-- Rollback must be hand-written if needed."
);
OperationSql {
label: format!("DropEnum {name}"),
up,
down,
lossy: Some(LossyRollbackWarning {
kind: LossyRollbackKind::DropEnum,
detail: format!("enum `{name}` dropped — variant list not recoverable from the diff"),
}),
}
}
fn emit_add_enum_variant(
enum_name: &str,
variant: &str,
anchor: Option<&EnumVariantAnchor>,
) -> OperationSql {
let qname = quote_ident(enum_name);
let lit = quote_string_literal(variant);
let up = match anchor {
None => format!("ALTER TYPE {qname} ADD VALUE {lit};"),
Some(EnumVariantAnchor {
variant: anchor_variant,
kind,
}) => {
let anchor_lit = quote_string_literal(anchor_variant);
let direction = match kind {
EnumVariantAnchorKind::Before => "BEFORE",
EnumVariantAnchorKind::After => "AFTER",
};
format!("ALTER TYPE {qname} ADD VALUE {lit} {direction} {anchor_lit};")
}
};
let down = format!(
"-- LOSSY ROLLBACK: Postgres has no `ALTER TYPE ... DROP VALUE`.\n\
-- Rolling back the addition of `{variant}` to enum `{enum_name}`\n\
-- requires rebuilding the type. Hand-write the rollback if needed."
);
OperationSql {
label: format!("AddEnumVariant {enum_name}:{variant}"),
up,
down,
lossy: Some(LossyRollbackWarning {
kind: LossyRollbackKind::DropEnum,
detail: format!(
"enum variant `{enum_name}:{variant}` added — Postgres has no \
native `DROP VALUE`; rollback requires a type rebuild"
),
}),
}
}
fn emit_rename_app(from: &str, to: &str) -> OperationSql {
let up = format!(
"-- METADATA-ONLY: rename app `{from}` to `{to}`.\n\
-- Folder rename + djogi_schema_migrations.app_label UPDATE happen\n\
-- outside the standard SQL emitter (handled by compose / runner)."
);
let down = format!(
"-- METADATA-ONLY: reverse rename `{to}` -> `{from}`.\n\
-- Folder rename + djogi_schema_migrations.app_label UPDATE happen\n\
-- outside the standard SQL emitter."
);
OperationSql {
label: format!("RenameApp {from} -> {to}"),
up,
down,
lossy: None,
}
}
fn emit_move_model_between_apps(model: &str, from_app: &str, to_app: &str) -> OperationSql {
let up = format!(
"-- METADATA-ONLY: move model `{model}` from app `{from_app}` to app `{to_app}`.\n\
-- Folder move + djogi_schema_migrations.app_label UPDATE happen outside\n\
-- the standard SQL emitter (handled by compose / runner)."
);
let down = format!(
"-- METADATA-ONLY: reverse move `{model}` from `{to_app}` back to `{from_app}`.\n\
-- Folder move + djogi_schema_migrations.app_label UPDATE happen outside\n\
-- the standard SQL emitter."
);
OperationSql {
label: format!("MoveModelBetweenApps {model} ({from_app} -> {to_app})"),
up,
down,
lossy: None,
}
}
fn emit_set_table_comment(table: &str, from: Option<&str>, to: Option<&str>) -> OperationSql {
let qt = quote_ident(table);
let render = |value: Option<&str>| -> String {
match value {
Some(text) => format!("COMMENT ON TABLE {qt} IS {};", render_comment_literal(text)),
None => format!("COMMENT ON TABLE {qt} IS NULL;"),
}
};
let up = render(to);
let down = render(from);
OperationSql {
label: format!("SetTableComment {table}"),
up,
down,
lossy: None,
}
}
#[allow(clippy::result_large_err)]
fn emit_set_storage_params(
table: &str,
from: Option<&str>,
to: Option<&str>,
) -> Result<OperationSql, SqlEmitError> {
#[allow(clippy::result_large_err)]
let render = |reset: Option<&str>, set: Option<&str>| -> Result<String, SqlEmitError> {
let mut out = String::new();
if let Some(params) = reset {
out.push_str(&render_reset_storage_params(table, params)?);
}
if let Some(params) = set {
if !out.is_empty() {
out.push('\n');
}
out.push_str(&render_set_storage_params(table, params)?);
}
Ok(if out.is_empty() {
format!(
"-- no-op storage parameter change for {}",
quote_ident(table)
)
} else {
out
})
};
Ok(OperationSql {
label: format!("SetStorageParams {table}"),
up: render(from, to)?,
down: render(to, from)?,
lossy: None,
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct StorageParamEntry {
key: String,
value: String,
}
#[allow(clippy::result_large_err)]
fn render_set_storage_params(table: &str, params: &str) -> Result<String, SqlEmitError> {
let qt = quote_ident(table);
let entries = parse_storage_params_for_sql(params)?;
Ok(format!(
"ALTER TABLE {qt} SET ({});",
render_storage_param_entries(&entries)
))
}
#[allow(clippy::result_large_err)]
fn render_reset_storage_params(table: &str, params: &str) -> Result<String, SqlEmitError> {
let qt = quote_ident(table);
let entries = parse_storage_params_for_sql(params)?;
let keys = entries
.iter()
.map(|entry| entry.key.as_str())
.collect::<Vec<_>>()
.join(", ");
Ok(format!("ALTER TABLE {qt} RESET ({keys});"))
}
#[allow(clippy::result_large_err)]
fn parse_storage_params_for_sql(params: &str) -> Result<Vec<StorageParamEntry>, SqlEmitError> {
parse_storage_params(params).map_err(|reason| SqlEmitError::InvalidStorageParams {
params: params.to_string(),
reason,
})
}
fn parse_storage_params(params: &str) -> Result<Vec<StorageParamEntry>, String> {
if params.trim().is_empty() {
return Err(
"storage_params must be a non-empty comma-separated key=value list".to_string(),
);
}
let mut seen = BTreeSet::new();
let mut entries = Vec::new();
for part in params.split(',') {
let part = part.trim();
if part.is_empty() {
return Err("storage_params entries must not be empty".to_string());
}
let Some((key, value)) = part.split_once('=') else {
return Err(
"storage_params entries must use key=value form separated by commas".to_string(),
);
};
if value.contains('=') {
return Err("storage_params entries must contain exactly one `=`".to_string());
}
let key = key.trim();
let value = value.trim();
validate_storage_param_key(key)?;
validate_storage_param_value(value)?;
let key = key.to_ascii_lowercase();
if !seen.insert(key.clone()) {
return Err(format!("duplicate storage_params key `{key}`"));
}
entries.push(StorageParamEntry {
key,
value: value.to_string(),
});
}
Ok(entries)
}
fn validate_storage_param_key(key: &str) -> Result<(), String> {
let bytes = key.as_bytes();
if bytes.is_empty() {
return Err("storage_params keys must not be empty".to_string());
}
if bytes.len() > 63 {
return Err("storage_params keys must be at most 63 bytes".to_string());
}
if !(bytes[0].is_ascii_alphabetic() || bytes[0] == b'_') {
return Err(
"storage_params keys must start with an ASCII letter or underscore".to_string(),
);
}
if !bytes
.iter()
.skip(1)
.all(|byte| byte.is_ascii_alphanumeric() || *byte == b'_')
{
return Err(
"storage_params keys must be plain ASCII reloption names; dotted keys are not supported"
.to_string(),
);
}
Ok(())
}
fn validate_storage_param_value(value: &str) -> Result<(), String> {
let bytes = value.as_bytes();
if bytes.is_empty() {
return Err("storage_params values must not be empty".to_string());
}
if is_storage_param_word(bytes) {
if is_storage_param_sql_control_word(bytes) {
return Err(
"storage_params values must not be SQL statement/control words".to_string(),
);
}
return Ok(());
}
if is_storage_param_number(bytes) {
return Ok(());
}
Err(
"storage_params values must be bare words or decimal numbers; quotes, comments, commas, \
parentheses, semicolons, and SQL expressions are not supported"
.to_string(),
)
}
fn is_storage_param_word(bytes: &[u8]) -> bool {
(bytes[0].is_ascii_alphabetic() || bytes[0] == b'_')
&& bytes
.iter()
.skip(1)
.all(|byte| byte.is_ascii_alphanumeric() || *byte == b'_')
}
fn is_storage_param_sql_control_word(bytes: &[u8]) -> bool {
let word = bytes
.iter()
.map(|byte| byte.to_ascii_lowercase())
.collect::<Vec<_>>();
matches!(
word.as_slice(),
b"alter"
| b"begin"
| b"call"
| b"comment"
| b"commit"
| b"copy"
| b"create"
| b"delete"
| b"do"
| b"drop"
| b"execute"
| b"from"
| b"grant"
| b"insert"
| b"reset"
| b"revoke"
| b"rollback"
| b"select"
| b"set"
| b"table"
| b"truncate"
| b"union"
| b"update"
| b"where"
)
}
fn is_storage_param_number(bytes: &[u8]) -> bool {
let mut seen_dot = false;
let mut digits_before_dot = 0usize;
let mut digits_after_dot = 0usize;
for byte in bytes {
if byte.is_ascii_digit() {
if seen_dot {
digits_after_dot += 1;
} else {
digits_before_dot += 1;
}
} else if *byte == b'.' && !seen_dot {
seen_dot = true;
} else {
return false;
}
}
digits_before_dot > 0 && (!seen_dot || digits_after_dot > 0)
}
fn render_storage_param_entries(entries: &[StorageParamEntry]) -> String {
let mut out = String::new();
for (idx, entry) in entries.iter().enumerate() {
if idx > 0 {
out.push_str(", ");
}
out.push_str(&entry.key);
out.push('=');
out.push_str(&entry.value);
}
out
}
fn emit_set_tablespace(table: &str, from: Option<&str>, to: Option<&str>) -> OperationSql {
OperationSql {
label: format!("SetTablespace {table}"),
up: render_set_tablespace(table, to),
down: render_set_tablespace(table, from),
lossy: None,
}
}
fn render_set_tablespace(table: &str, tablespace: Option<&str>) -> String {
let qt = quote_ident(table);
let qs = quote_ident(tablespace.unwrap_or("pg_default"));
format!("ALTER TABLE {qt} SET TABLESPACE {qs};")
}
fn render_comment_on_column(table: &str, column: &str, value: Option<&str>) -> String {
let qt = quote_ident(table);
let qc = quote_ident(column);
match value {
Some(text) => format!(
"COMMENT ON COLUMN {qt}.{qc} IS {};",
render_comment_literal(text)
),
None => format!("COMMENT ON COLUMN {qt}.{qc} IS NULL;"),
}
}
fn render_comment_literal(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 3);
out.push_str("E'");
for ch in s.chars() {
match ch {
'\'' => out.push_str("''"),
'\\' => out.push_str("\\\\"),
_ => out.push(ch),
}
}
out.push('\'');
out
}
pub(crate) fn quote_ident(name: &str) -> String {
let mut out = String::with_capacity(name.len() + 2);
out.push('"');
for b in name.as_bytes() {
if *b == b'"' {
out.push('"');
out.push('"');
} else {
out.push(*b as char);
}
}
out.push('"');
out
}
pub(crate) fn quote_string_literal(value: &str) -> String {
let mut out = String::with_capacity(value.len() + 2);
out.push('\'');
for b in value.as_bytes() {
if *b == b'\'' {
out.push('\'');
out.push('\'');
} else {
out.push(*b as char);
}
}
out.push('\'');
out
}
fn type_change_likely_requires_using(from: &str, to: &str) -> bool {
fn lower_ascii(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.as_bytes() {
if b.is_ascii_uppercase() {
out.push((*b + 32) as char);
} else {
out.push(*b as char);
}
}
out
}
let f = lower_ascii(from);
let t = lower_ascii(to);
fn base_name(s: &str) -> &str {
match s.find('(') {
Some(idx) => s[..idx].trim_end(),
None => s.trim_end(),
}
}
let f_base = base_name(&f);
let t_base = base_name(&t);
let is_text = |b: &str| {
b == "text"
|| b == "citext"
|| b.starts_with("varchar")
|| b.starts_with("character varying")
|| b.starts_with("character")
|| b.starts_with("char")
};
let is_integer = |b: &str| {
matches!(
b,
"smallint" | "int2" | "integer" | "int4" | "int" | "bigint" | "int8"
)
};
let is_uuid = |b: &str| b == "uuid";
if (is_text(f_base) && is_uuid(t_base)) || (is_uuid(f_base) && is_text(t_base)) {
return true;
}
if (is_text(f_base) && is_integer(t_base)) || (is_integer(f_base) && is_text(t_base)) {
return true;
}
if (is_uuid(f_base) && is_integer(t_base)) || (is_integer(f_base) && is_uuid(t_base)) {
return true;
}
false
}
fn check_constraint_name(table: &str, column: &str) -> String {
truncate_constraint(format!("{table}_{column}_check"))
}
fn unique_constraint_name(table: &str, column: &str) -> String {
truncate_constraint(format!("{table}_{column}_key"))
}
pub(crate) fn fk_constraint_name(table: &str, column: &str) -> String {
truncate_constraint(format!("{table}_{column}_fkey"))
}
#[cfg(test)]
fn enforced_suffix(enforced: bool) -> &'static str {
if enforced { "" } else { " NOT ENFORCED" }
}
#[cfg(test)]
fn render_temporal_primary_key_body(constraint: &TemporalPrimaryKeyConstraintSchema) -> String {
let mut parts: Vec<String> = constraint.columns.iter().map(|c| quote_ident(c)).collect();
parts.push(format!(
"{} WITHOUT OVERLAPS",
quote_ident(&constraint.without_overlaps_column)
));
let mut out = format!("PRIMARY KEY ({})", parts.join(", "));
if !constraint.include.is_empty() {
let include = constraint
.include
.iter()
.map(|c| quote_ident(c))
.collect::<Vec<_>>()
.join(", ");
let _ = write!(out, " INCLUDE ({include})");
}
out
}
#[cfg(test)]
fn render_period_foreign_key_body(constraint: &PeriodForeignKeyConstraintSchema) -> String {
let source_cols = render_period_column_list(&constraint.columns, &constraint.period_column);
let ref_cols =
render_period_column_list(&constraint.ref_columns, &constraint.ref_period_column);
let mut out = format!(
"FOREIGN KEY ({source_cols}) REFERENCES {} ({ref_cols})",
quote_ident(&constraint.ref_table),
);
if constraint.deferrable {
out.push_str(" DEFERRABLE");
if constraint.initially_deferred {
out.push_str(" INITIALLY DEFERRED");
} else {
out.push_str(" INITIALLY IMMEDIATE");
}
}
out.push_str(enforced_suffix(constraint.enforced));
out
}
#[cfg(test)]
fn render_period_column_list(columns: &[String], period_column: &str) -> String {
let mut parts: Vec<String> = columns.iter().map(|c| quote_ident(c)).collect();
parts.push(format!("PERIOD {}", quote_ident(period_column)));
parts.join(", ")
}
fn truncate_constraint(name: String) -> String {
if name.len() <= 63 {
return name;
}
use std::hash::{BuildHasher, BuildHasherDefault, Hasher};
let mut h =
BuildHasherDefault::<std::collections::hash_map::DefaultHasher>::default().build_hasher();
h.write(name.as_bytes());
let raw = h.finish();
let digest = format!("{:08x}", raw as u32);
let stem: String = name.as_bytes()[..54].iter().map(|b| *b as char).collect();
format!("{stem}_{digest}")
}
fn write_column_definition(out: &mut String, col: &ColumnSchema, table: &str) {
let qn = quote_ident(&col.name);
out.push_str(&qn);
out.push(' ');
out.push_str(&col.sql_type);
if !col.nullable {
out.push_str(" NOT NULL");
}
if let Some(identity) = col.identity {
out.push(' ');
out.push_str(identity.sql_clause());
} else if let Some(generated) = &col.generated {
let stored_clause = if generated.stored {
"STORED"
} else {
"VIRTUAL"
};
let _ = write!(
out,
" GENERATED ALWAYS AS ({}) {stored_clause}",
generated.expression,
);
} else if let Some(def) = &col.default_sql {
let _ = write!(out, " DEFAULT {def}");
}
if col.unique {
out.push_str(" UNIQUE");
}
if let Some(check) = &col.check {
let constraint = check_constraint_name(table, &col.name);
let qcons = quote_ident(&constraint);
let _ = write!(out, " CONSTRAINT {qcons} CHECK ({check})");
}
if let Some(fk) = &col.foreign_key {
let constraint = fk_constraint_name(table, &col.name);
let qcons = quote_ident(&constraint);
let qref_t = quote_ident(&fk.ref_table);
let qref_c = quote_ident(&fk.ref_column);
let _ = write!(out, " CONSTRAINT {qcons} REFERENCES {qref_t} ({qref_c})");
let _ = write!(out, " ON DELETE {}", on_delete_sql(fk.on_delete));
out.push_str(render_deferrable_clause(
fk.deferrable,
fk.initially_deferred,
));
}
}
fn pk_table_clause(t: &TableSchema) -> Option<String> {
if matches!(t.primary_key.kind, PkKindSchema::None) || t.primary_key.columns.is_empty() {
return None;
}
let mut s = String::with_capacity(64);
s.push_str("PRIMARY KEY (");
let mut first = true;
for col in &t.primary_key.columns {
if !first {
s.push_str(", ");
}
first = false;
s.push_str("e_ident(col));
}
s.push(')');
Some(s)
}
fn partition_clause(p: &PartitionSchema) -> String {
match p {
PartitionSchema::Range { column } => {
format!("PARTITION BY RANGE ({})", quote_ident(column))
}
PartitionSchema::Hash { column, partitions } => {
format!(
"PARTITION BY HASH ({}) /* partitions = {} */",
quote_ident(column),
partitions
)
}
}
}
fn write_index_target(out: &mut String, target: &IndexTargetSchema) {
match target {
IndexTargetSchema::Columns(cols) => {
out.push('(');
let mut first = true;
for c in cols {
if !first {
out.push_str(", ");
}
first = false;
write_index_column(out, c);
}
out.push(')');
}
IndexTargetSchema::Expression(expr) => {
let _ = write!(out, "(({expr}))");
}
}
}
fn write_constraint_column_list(out: &mut String, target: &IndexTargetSchema) {
match target {
IndexTargetSchema::Columns(cols) => {
out.push('(');
let mut first = true;
for c in cols {
if !first {
out.push_str(", ");
}
first = false;
out.push_str("e_ident(&c.name));
}
out.push(')');
}
IndexTargetSchema::Expression(expr) => {
let _ = write!(
out,
"(/* expression cannot appear in UNIQUE table constraint; \
use `CREATE UNIQUE INDEX` form instead (got: {expr}) */)"
);
}
}
}
fn write_index_column(out: &mut String, c: &IndexColumnSchema) {
out.push_str("e_ident(&c.name));
if let Some(opclass) = &c.opclass {
let _ = write!(out, " {opclass}");
}
match c.order {
IndexOrderSchema::Asc => {} IndexOrderSchema::Desc => out.push_str(" DESC"),
}
match c.nulls {
IndexNullsOrderSchema::Default => {} IndexNullsOrderSchema::First => out.push_str(" NULLS FIRST"),
IndexNullsOrderSchema::Last => out.push_str(" NULLS LAST"),
}
}
fn recreate_index_sql(idx: &IndexSchema) -> String {
emit_add_index(idx).up
}
fn index_method(t: IndexTypeSchema) -> &'static str {
match t {
IndexTypeSchema::BTree => "btree",
IndexTypeSchema::Gin => "gin",
IndexTypeSchema::Gist => "gist",
IndexTypeSchema::Hash => "hash",
IndexTypeSchema::Spgist => "spgist",
IndexTypeSchema::Brin => "brin",
}
}
fn on_delete_sql(d: OnDeleteSchema) -> &'static str {
match d {
OnDeleteSchema::Restrict => "RESTRICT",
OnDeleteSchema::Cascade => "CASCADE",
OnDeleteSchema::SetNull => "SET NULL",
OnDeleteSchema::SetDefault => "SET DEFAULT",
OnDeleteSchema::NoAction => "NO ACTION",
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::migrate::schema::{
AppliedSchema, ColumnSchema, ForeignKeySchema, IndexColumnSchema, IndexKindSchema,
IndexNullsOrderSchema, IndexOrderSchema, IndexSchema, IndexTargetSchema, IndexTypeSchema,
NamedNotNullConstraintSchema, OnDeleteSchema, PeriodForeignKeyConstraintSchema,
PkKindSchema, PrimaryKeySchema, RelationKindSchema, TableSchema,
TemporalPrimaryKeyConstraintSchema,
};
fn col(name: &str, ty: &str, nullable: bool) -> ColumnSchema {
ColumnSchema {
check: None,
codec: 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 pk_id_heerid() -> PrimaryKeySchema {
PrimaryKeySchema {
columns: vec!["id".to_string()],
kind: PkKindSchema::HeerId,
}
}
fn id_column_heerid() -> ColumnSchema {
ColumnSchema {
default_sql: Some("heerid_next()".to_string()),
..col("id", "BIGINT", false)
}
}
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: pk_id_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 table_with_amount_and_check(
table: &str,
sql_type: &str,
check: Option<&str>,
) -> TableSchema {
let mut t = synth_table(table);
t.columns = vec![
id_column_heerid(),
ColumnSchema {
check: check.map(|s| s.to_string()),
codec: None,
comment: None,
default_sql: None,
foreign_key: None,
generated: None,
identity: None,
index_type: None,
indexed: false,
max_length: None,
name: "amount".to_string(),
nullable: false,
on_delete: None,
outbox_exclude: false,
rationale: None,
relation_kind: None,
renamed_from: None,
sequence_within: None,
sql_type: sql_type.to_string(),
unique: false,
type_change_using: None,
},
];
t
}
fn applied_schema_with_amount_check(sql_type: &str, check: Option<&str>) -> AppliedSchema {
let mut models = std::collections::BTreeMap::new();
models.insert(
"widgets".to_string(),
table_with_amount_and_check("widgets", sql_type, check),
);
AppliedSchema {
djogi_version: "0.1.0".to_string(),
enums: std::collections::BTreeMap::new(),
format_version: "1".to_string(),
generated_at: "2026-05-10T00:00:00Z".to_string(),
indexes: vec![],
models,
registered_apps: vec!["".to_string()],
}
}
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 quote_ident_quotes_simple_name() {
assert_eq!(quote_ident("users"), "\"users\"");
}
#[test]
fn quote_ident_doubles_embedded_quote() {
assert_eq!(quote_ident("a\"b"), "\"a\"\"b\"");
}
#[test]
fn quote_string_literal_doubles_embedded_quote() {
assert_eq!(quote_string_literal("it's"), "'it''s'");
}
#[test]
fn add_table_emits_create_table_with_columns_and_pk() {
let t = synth_table("users");
let sql = emit_add_table(&t);
assert!(sql.up.starts_with("CREATE TABLE \"users\" ("));
assert!(
sql.up
.contains("\"id\" BIGINT NOT NULL DEFAULT heerid_next()")
);
assert!(sql.up.contains("\"name\" TEXT"));
assert!(sql.up.contains("PRIMARY KEY (\"id\")"));
assert!(sql.up.ends_with(";"));
assert_eq!(sql.down, "DROP TABLE \"users\";");
assert!(sql.lossy.is_none());
}
#[test]
fn add_table_with_partition_emits_partition_clause() {
let mut t = synth_table("events");
t.partition = Some(PartitionSchema::Range {
column: "created_at".to_string(),
});
let sql = emit_add_table(&t);
assert!(
sql.up.contains("PARTITION BY RANGE (\"created_at\")"),
"got: {}",
sql.up
);
}
#[test]
fn add_table_with_hash_partition_carries_partition_count() {
let mut t = synth_table("shards");
t.partition = Some(PartitionSchema::Hash {
column: "id".to_string(),
partitions: 8,
});
let sql = emit_add_table(&t);
assert!(sql.up.contains("PARTITION BY HASH (\"id\")"));
assert!(sql.up.contains("/* partitions = 8 */"));
}
#[test]
fn add_table_with_composite_pk_emits_composite_constraint() {
let mut t = synth_table("memberships");
t.primary_key = PrimaryKeySchema {
columns: vec!["user_id".to_string(), "role_id".to_string()],
kind: PkKindSchema::Composite,
};
t.columns = vec![
col("user_id", "BIGINT", false),
col("role_id", "BIGINT", false),
];
let sql = emit_add_table(&t);
assert!(
sql.up.contains("PRIMARY KEY (\"user_id\", \"role_id\")"),
"got: {}",
sql.up
);
}
#[test]
fn add_table_with_no_pk_skips_constraint_clause() {
let mut t = synth_table("audit_events");
t.primary_key = PrimaryKeySchema {
columns: Vec::new(),
kind: PkKindSchema::None,
};
t.columns = vec![col("event", "TEXT", false)];
let sql = emit_add_table(&t);
assert!(!sql.up.contains("PRIMARY KEY"));
}
#[test]
fn add_table_with_fk_column_inlines_references_clause() {
let fk_col = ColumnSchema {
foreign_key: Some(ForeignKeySchema {
deferrable: false,
initially_deferred: false,
on_delete: OnDeleteSchema::Cascade,
ref_column: "id".to_string(),
ref_table: "users".to_string(),
}),
on_delete: Some(OnDeleteSchema::Cascade),
relation_kind: Some(RelationKindSchema::ForeignKey),
..col("user_id", "BIGINT", false)
};
let mut t = synth_table("posts");
t.columns = vec![id_column_heerid(), fk_col];
let sql = emit_add_table(&t);
assert!(
sql.up
.contains("REFERENCES \"users\" (\"id\") ON DELETE CASCADE"),
"got: {}",
sql.up
);
}
#[test]
fn add_table_inline_fk_propagates_cascade_kind() {
for (cascade, expected) in [
(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"),
] {
let fk_col = ColumnSchema {
foreign_key: Some(ForeignKeySchema {
deferrable: false,
initially_deferred: false,
on_delete: cascade,
ref_column: "id".to_string(),
ref_table: "users".to_string(),
}),
on_delete: Some(OnDeleteSchema::Restrict),
relation_kind: Some(RelationKindSchema::ForeignKey),
..col("user_id", "BIGINT", false)
};
let mut t = synth_table("posts");
t.columns = vec![id_column_heerid(), fk_col];
let sql = emit_add_table(&t);
assert!(
sql.up.contains(expected),
"inline FK cascade {cascade:?} must emit `{expected}`; \
got: {}",
sql.up
);
}
}
#[test]
fn render_create_table_with_deferrable_fk() {
let fk_col = ColumnSchema {
foreign_key: Some(ForeignKeySchema {
deferrable: true,
initially_deferred: true,
on_delete: OnDeleteSchema::Restrict,
ref_column: "id".to_string(),
ref_table: "users".to_string(),
}),
on_delete: Some(OnDeleteSchema::Restrict),
relation_kind: Some(RelationKindSchema::ForeignKey),
..col("user_id", "BIGINT", false)
};
let mut t = synth_table("posts");
t.columns = vec![id_column_heerid(), fk_col];
let sql = emit_add_table(&t);
assert!(
sql.up.contains(
"REFERENCES \"users\" (\"id\") ON DELETE RESTRICT \
DEFERRABLE INITIALLY DEFERRED"
),
"got: {}",
sql.up
);
}
#[test]
fn render_create_table_with_immediately_deferrable_fk() {
let fk_col = ColumnSchema {
foreign_key: Some(ForeignKeySchema {
deferrable: true,
initially_deferred: false,
on_delete: OnDeleteSchema::Restrict,
ref_column: "id".to_string(),
ref_table: "users".to_string(),
}),
on_delete: Some(OnDeleteSchema::Restrict),
relation_kind: Some(RelationKindSchema::ForeignKey),
..col("user_id", "BIGINT", false)
};
let mut t = synth_table("posts");
t.columns = vec![id_column_heerid(), fk_col];
let sql = emit_add_table(&t);
assert!(
sql.up.contains(
"REFERENCES \"users\" (\"id\") ON DELETE RESTRICT \
DEFERRABLE INITIALLY IMMEDIATE"
),
"got: {}",
sql.up
);
}
#[test]
fn render_create_table_with_non_deferrable_fk() {
let fk_col = ColumnSchema {
foreign_key: Some(ForeignKeySchema {
deferrable: false,
initially_deferred: false,
on_delete: OnDeleteSchema::Restrict,
ref_column: "id".to_string(),
ref_table: "users".to_string(),
}),
on_delete: Some(OnDeleteSchema::Restrict),
relation_kind: Some(RelationKindSchema::ForeignKey),
..col("user_id", "BIGINT", false)
};
let mut t = synth_table("posts");
t.columns = vec![id_column_heerid(), fk_col];
let sql = emit_add_table(&t);
assert!(
!sql.up.contains("DEFERRABLE"),
"non-deferrable FK must not emit a deferrability clause: {}",
sql.up
);
}
#[test]
fn add_table_inline_fk_names_constraint_explicitly_short_name() {
let fk_col = ColumnSchema {
foreign_key: Some(ForeignKeySchema {
deferrable: true,
initially_deferred: false,
on_delete: OnDeleteSchema::Restrict,
ref_column: "id".to_string(),
ref_table: "users".to_string(),
}),
on_delete: Some(OnDeleteSchema::Restrict),
relation_kind: Some(RelationKindSchema::ForeignKey),
..col("user_id", "BIGINT", false)
};
let mut t = synth_table("posts");
t.columns = vec![id_column_heerid(), fk_col];
let sql = emit_add_table(&t);
let expected_name = fk_constraint_name("posts", "user_id");
assert_eq!(
expected_name, "posts_user_id_fkey",
"short-name sanity: the convention must produce the verbatim \
`{{table}}_{{column}}_fkey` for inputs that fit inside 63 bytes",
);
assert!(
sql.up
.contains(" CONSTRAINT \"posts_user_id_fkey\" REFERENCES \"users\" (\"id\")"),
"inline FK must emit explicit `CONSTRAINT <name> REFERENCES ...`; \
got: {}",
sql.up
);
assert!(
sql.up
.contains("ON DELETE RESTRICT DEFERRABLE INITIALLY IMMEDIATE"),
"explicit constraint name must preserve cascade + deferrability \
order; got: {}",
sql.up
);
}
#[test]
fn add_table_inline_fk_uses_djogi_hashed_name_for_long_identifiers() {
let table = "djogi_some_very_long_table_for_fk_regression";
let column = "author_user_account_id_reference";
let conventional = format!("{table}_{column}_fkey");
assert!(
conventional.len() > 63,
"test precondition: conventional name {conventional:?} \
({} bytes) must exceed Postgres' 63-byte limit so the \
hashed branch is exercised",
conventional.len(),
);
let fk_col = ColumnSchema {
foreign_key: Some(ForeignKeySchema {
deferrable: true,
initially_deferred: true,
on_delete: OnDeleteSchema::Cascade,
ref_column: "id".to_string(),
ref_table: "users".to_string(),
}),
on_delete: Some(OnDeleteSchema::Cascade),
relation_kind: Some(RelationKindSchema::ForeignKey),
..col(column, "BIGINT", false)
};
let mut t = synth_table(table);
t.columns = vec![id_column_heerid(), fk_col];
let sql = emit_add_table(&t);
let expected_name = fk_constraint_name(table, column);
assert_eq!(
expected_name.len(),
63,
"convention must produce exactly 63 bytes for over-long inputs; \
got {} bytes: {expected_name}",
expected_name.len(),
);
assert_ne!(
expected_name, conventional,
"the convention must NOT equal the conventional name when the \
input would overflow; otherwise the regression is not exercised",
);
let expected_fragment =
format!(" CONSTRAINT \"{expected_name}\" REFERENCES \"users\" (\"id\")");
assert!(
sql.up.contains(&expected_fragment),
"emitted DDL must contain `{expected_fragment}` so the runtime \
`defer_constraints` validator's expected name matches; got: {}",
sql.up
);
assert!(
sql.up
.contains("ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED"),
"explicit constraint name must preserve cascade + deferrability \
order; got: {}",
sql.up
);
let runtime_expected = fk_constraint_name(table, column);
assert!(
sql.up.contains(&format!("\"{runtime_expected}\"")),
"runtime validator's derived name `{runtime_expected}` must \
appear verbatim in emitted DDL; got: {}",
sql.up
);
}
#[test]
fn add_column_inline_fk_uses_explicit_constraint_name() {
let column = "author_user_account_id_reference";
let fk_col = ColumnSchema {
foreign_key: Some(ForeignKeySchema {
deferrable: true,
initially_deferred: false,
on_delete: OnDeleteSchema::SetNull,
ref_column: "id".to_string(),
ref_table: "users".to_string(),
}),
on_delete: Some(OnDeleteSchema::SetNull),
relation_kind: Some(RelationKindSchema::ForeignKey),
nullable: true,
..col(column, "BIGINT", true)
};
let sql = emit_add_column("djogi_some_very_long_table_for_fk_regression", &fk_col);
let expected_name =
fk_constraint_name("djogi_some_very_long_table_for_fk_regression", column);
assert_eq!(expected_name.len(), 63);
assert!(
sql.up.contains(&format!(
" CONSTRAINT \"{expected_name}\" REFERENCES \"users\" (\"id\")"
)),
"ALTER TABLE ADD COLUMN inline FK must carry the explicit \
`CONSTRAINT <hashed_name> REFERENCES ...` shape; got: {}",
sql.up
);
}
#[test]
fn drop_table_marks_lossy_rollback() {
let sql = emit_drop_table("orders");
assert_eq!(sql.up, "DROP TABLE \"orders\";");
assert!(sql.down.contains("LOSSY ROLLBACK"));
let warn = sql.lossy.expect("lossy warning");
assert!(matches!(warn.kind, LossyRollbackKind::DropTable));
}
#[test]
fn add_column_emits_alter_table_add_column() {
let c = col("email", "TEXT", false);
let sql = emit_add_column("users", &c);
assert_eq!(
sql.up,
"ALTER TABLE \"users\" ADD COLUMN \"email\" TEXT NOT NULL;"
);
assert_eq!(sql.down, "ALTER TABLE \"users\" DROP COLUMN \"email\";");
assert!(sql.lossy.is_some(), "AddColumn rollback drops a column");
}
#[test]
fn drop_column_marks_lossy_with_comment_only_down() {
let sql = emit_drop_column("users", "legacy_id");
assert_eq!(sql.up, "ALTER TABLE \"users\" DROP COLUMN \"legacy_id\";");
assert!(sql.down.contains("LOSSY ROLLBACK"));
let warn = sql.lossy.expect("lossy warning");
assert!(matches!(warn.kind, LossyRollbackKind::DropColumn));
}
#[test]
fn rename_column_round_trips_in_down() {
let sql = emit_rename_column("users", "old_name", "new_name");
assert_eq!(
sql.up,
"ALTER TABLE \"users\" RENAME COLUMN \"old_name\" TO \"new_name\";"
);
assert_eq!(
sql.down,
"ALTER TABLE \"users\" RENAME COLUMN \"new_name\" TO \"old_name\";"
);
assert!(sql.lossy.is_none());
}
#[test]
fn alter_column_set_not_null_round_trips() {
let sql = emit_alter_column("users", "email", &ColumnChange::SetNullable(false));
assert_eq!(
sql.up,
"ALTER TABLE \"users\" ALTER COLUMN \"email\" SET NOT NULL;"
);
assert_eq!(
sql.down,
"ALTER TABLE \"users\" ALTER COLUMN \"email\" DROP NOT NULL;"
);
}
#[test]
fn alter_column_change_type_emits_using_cast() {
let sql = emit_alter_column(
"events",
"amount",
&ColumnChange::ChangeType {
from: "TEXT".to_string(),
to: "BIGINT".to_string(),
using: None,
},
);
assert!(sql.up.contains("TYPE BIGINT USING \"amount\"::BIGINT"));
assert!(sql.down.contains("TYPE TEXT USING \"amount\"::TEXT"));
}
#[test]
fn alter_column_change_type_with_using_inlines_adopter_expression() {
let sql = emit_alter_column(
"items",
"kind",
&ColumnChange::ChangeType {
from: "TEXT".to_string(),
to: "UUID".to_string(),
using: Some("kind::uuid".to_string()),
},
);
assert!(
sql.up.contains(
"ALTER TABLE \"items\" ALTER COLUMN \"kind\" TYPE UUID USING (kind::uuid);"
),
"UP must inline the adopter USING expression verbatim: {}",
sql.up
);
assert!(
!sql.up.contains("USING \"kind\"::UUID"),
"UP must not also emit the default cast when adopter `using` is set: {}",
sql.up
);
assert!(
sql.down.contains(
"ALTER TABLE \"items\" ALTER COLUMN \"kind\" TYPE TEXT USING \"kind\"::TEXT;"
),
"DOWN must use default cast: {}",
sql.down
);
let lossy = sql
.lossy
.as_ref()
.expect("forward `using.is_some()` must surface LossyRollbackWarning");
assert!(matches!(lossy.kind, LossyRollbackKind::CustomCast));
assert!(
lossy.detail.contains("items") && lossy.detail.contains("kind"),
"lossy detail must identify the column: {}",
lossy.detail
);
}
#[test]
fn alter_column_change_type_without_using_emits_no_lossy_marker() {
let sql = emit_alter_column(
"events",
"amount",
&ColumnChange::ChangeType {
from: "INTEGER".to_string(),
to: "BIGINT".to_string(),
using: None,
},
);
assert!(
sql.lossy.is_none(),
"default-cast ChangeType must not surface a lossy marker: {:?}",
sql.lossy
);
}
#[test]
fn alter_column_change_type_warns_for_known_incompatible_pair_without_using() {
let sql = emit_alter_column(
"items",
"kind",
&ColumnChange::ChangeType {
from: "TEXT".to_string(),
to: "UUID".to_string(),
using: None,
},
);
assert!(
sql.up.contains("-- WARNING:"),
"known-incompatible cast without `using` must prepend a WARNING comment: {}",
sql.up
);
assert!(
sql.up.contains("type_change_using"),
"WARNING comment must name the corrective attribute: {}",
sql.up
);
assert!(
sql.up.contains(
"ALTER TABLE \"items\" ALTER COLUMN \"kind\" TYPE UUID USING \"kind\"::UUID;"
),
"default cast is still emitted (warning is a hint, not a refusal): {}",
sql.up
);
}
#[test]
fn alter_column_change_type_no_warning_for_widening_pair() {
let sql = emit_alter_column(
"events",
"counter",
&ColumnChange::ChangeType {
from: "INTEGER".to_string(),
to: "BIGINT".to_string(),
using: None,
},
);
assert!(
!sql.up.contains("-- WARNING:"),
"widening cast pair must not emit a WARNING comment: {}",
sql.up
);
assert!(
sql.up.contains("TYPE BIGINT USING \"counter\"::BIGINT"),
"default cast still emitted: {}",
sql.up
);
}
#[test]
fn alter_column_change_type_warning_heuristic_is_case_insensitive() {
for (from, to) in [
("text", "uuid"),
("TEXT", "uuid"),
("varchar(64)", "INTEGER"),
("UUID", "bigint"),
] {
let sql = emit_alter_column(
"t",
"c",
&ColumnChange::ChangeType {
from: from.to_string(),
to: to.to_string(),
using: None,
},
);
assert!(
sql.up.contains("-- WARNING:"),
"expected WARNING for {from} -> {to}: {}",
sql.up
);
}
}
#[test]
fn alter_column_change_type_warning_suppressed_when_using_provided() {
let sql = emit_alter_column(
"items",
"kind",
&ColumnChange::ChangeType {
from: "TEXT".to_string(),
to: "UUID".to_string(),
using: Some("kind::uuid".to_string()),
},
);
assert!(
!sql.up.contains("-- WARNING:"),
"explicit `using` suppresses the heuristic warning: {}",
sql.up
);
}
#[test]
fn type_change_likely_requires_using_recognises_known_pairs() {
assert!(type_change_likely_requires_using("TEXT", "UUID"));
assert!(type_change_likely_requires_using("UUID", "TEXT"));
assert!(type_change_likely_requires_using("VARCHAR(64)", "UUID"));
assert!(type_change_likely_requires_using("TEXT", "INTEGER"));
assert!(type_change_likely_requires_using("BIGINT", "TEXT"));
assert!(type_change_likely_requires_using("UUID", "BIGINT"));
assert!(type_change_likely_requires_using("citext", "uuid"));
assert!(type_change_likely_requires_using("text", "smallint"));
assert!(type_change_likely_requires_using(
"CHARACTER VARYING(255)",
"uuid"
));
}
#[test]
fn type_change_likely_requires_using_passes_implicit_casts() {
assert!(!type_change_likely_requires_using("INTEGER", "BIGINT"));
assert!(!type_change_likely_requires_using("SMALLINT", "INTEGER"));
assert!(!type_change_likely_requires_using("VARCHAR(64)", "TEXT"));
assert!(!type_change_likely_requires_using("TEXT", "CITEXT"));
assert!(!type_change_likely_requires_using(
"NUMERIC(10, 2)",
"NUMERIC(12, 2)"
));
assert!(!type_change_likely_requires_using(
"TIMESTAMPTZ",
"TIMESTAMPTZ"
));
}
#[test]
fn alter_column_set_check_uses_named_constraint() {
let sql = emit_alter_column(
"users",
"email",
&ColumnChange::SetCheck {
from: None,
to: Some("email <> ''".to_string()),
},
);
assert!(sql.up.contains("ADD CONSTRAINT \"users_email_check\""));
assert!(sql.up.contains("CHECK (email <> '')"));
assert!(sql.down.contains("DROP CONSTRAINT \"users_email_check\""));
assert!(
!sql.down.contains("ADD CONSTRAINT"),
"ADD-only rollback must not re-add: {}",
sql.down
);
}
#[test]
fn alter_column_set_check_for_i8_smallint() {
let sql = emit_alter_column(
"widgets",
"maybe_signed_byte",
&ColumnChange::SetCheck {
from: None,
to: Some(
"\"maybe_signed_byte\" >= -128 AND \"maybe_signed_byte\" <= 127".to_string(),
),
},
);
assert!(
sql.up
.contains("ADD CONSTRAINT \"widgets_maybe_signed_byte_check\""),
"i8 CHECK uses table+column constraint name: {}",
sql.up
);
assert!(
sql.up
.contains("CHECK (\"maybe_signed_byte\" >= -128 AND \"maybe_signed_byte\" <= 127)"),
"i8 CHECK expression wraps the projected bound: {}",
sql.up
);
assert!(
sql.down
.contains("DROP CONSTRAINT \"widgets_maybe_signed_byte_check\""),
"i8 CHECK rollback drops the named constraint: {}",
sql.down
);
}
#[test]
fn alter_column_set_check_for_u32_bigint() {
let sql = emit_alter_column(
"widgets",
"medium_count",
&ColumnChange::SetCheck {
from: None,
to: Some("\"medium_count\" >= 0 AND \"medium_count\" <= 4294967295".to_string()),
},
);
assert!(
sql.up
.contains("ADD CONSTRAINT \"widgets_medium_count_check\""),
"u32 CHECK uses table+column constraint name: {}",
sql.up
);
assert!(
sql.up
.contains("CHECK (\"medium_count\" >= 0 AND \"medium_count\" <= 4294967295)"),
"u32 CHECK expression wraps the projected bound: {}",
sql.up
);
assert!(
sql.down
.contains("DROP CONSTRAINT \"widgets_medium_count_check\""),
"u32 CHECK rollback drops the named constraint: {}",
sql.down
);
}
#[test]
fn alter_column_set_check_for_u64_numeric() {
let sql = emit_alter_column(
"widgets",
"huge_count",
&ColumnChange::SetCheck {
from: None,
to: Some(
"\"huge_count\" >= 0 AND \"huge_count\" <= 18446744073709551615".to_string(),
),
},
);
assert!(
sql.up
.contains("ADD CONSTRAINT \"widgets_huge_count_check\""),
"u64 CHECK uses table+column constraint name: {}",
sql.up
);
assert!(
sql.up
.contains("CHECK (\"huge_count\" >= 0 AND \"huge_count\" <= 18446744073709551615)"),
"u64 CHECK expression wraps the projected bound: {}",
sql.up
);
}
#[test]
fn alter_column_set_check_for_time_date() {
let sql = emit_alter_column(
"products",
"launch_date",
&ColumnChange::SetCheck {
from: None,
to: Some("\"launch_date\" <= DATE '9999-12-31'".to_string()),
},
);
assert!(
sql.up
.contains("ADD CONSTRAINT \"products_launch_date_check\""),
"Date CHECK uses table+column constraint name: {}",
sql.up
);
assert!(
sql.up
.contains("CHECK (\"launch_date\" <= DATE '9999-12-31')"),
"Date CHECK expression wraps the projected bound: {}",
sql.up
);
assert!(
sql.down
.contains("DROP CONSTRAINT \"products_launch_date_check\""),
"Date CHECK rollback drops the named constraint: {}",
sql.down
);
}
#[test]
fn alter_column_set_check_for_time_offset_datetime() {
let sql = emit_alter_column(
"events",
"occurred_at",
&ColumnChange::SetCheck {
from: None,
to: Some(
"\"occurred_at\" <= TIMESTAMPTZ '9999-12-31 23:59:59.999999+00'".to_string(),
),
},
);
assert!(
sql.up
.contains("ADD CONSTRAINT \"events_occurred_at_check\""),
"Timestamptz CHECK uses table+column constraint name: {}",
sql.up
);
assert!(
sql.up
.contains("CHECK (\"occurred_at\" <= TIMESTAMPTZ '9999-12-31 23:59:59.999999+00')"),
"Timestamptz CHECK expression wraps the projected bound: {}",
sql.up
);
assert!(
sql.down
.contains("DROP CONSTRAINT \"events_occurred_at_check\""),
"Timestamptz CHECK rollback drops the named constraint: {}",
sql.down
);
}
#[test]
fn alter_column_drop_check_emits_drop_constraint_only() {
let prior = "\"medium_count\" >= 0 AND \"medium_count\" <= 4294967295";
let sql = emit_alter_column(
"widgets",
"medium_count",
&ColumnChange::SetCheck {
from: Some(prior.to_string()),
to: None,
},
);
assert!(
sql.up
.contains("DROP CONSTRAINT \"widgets_medium_count_check\""),
"drop CHECK emits a named DROP CONSTRAINT: {}",
sql.up
);
assert!(
!sql.up.contains("ADD CONSTRAINT"),
"drop CHECK must not also add a constraint on the up side: {}",
sql.up
);
assert!(
sql.down
.contains("ADD CONSTRAINT \"widgets_medium_count_check\""),
"drop CHECK rollback must ADD the prior constraint: {}",
sql.down
);
assert!(
sql.down.contains(&format!("CHECK ({prior})")),
"drop CHECK rollback must restore the prior expression: {}",
sql.down
);
assert!(
sql.lossy.is_none(),
"drop CHECK rollback is lossless when `from` is known: {:?}",
sql.lossy
);
}
#[test]
fn alter_column_amend_check_pair_emits_drop_then_add() {
let prior_expr = "\"amount\" >= 0 AND \"amount\" <= 4294967295";
let new_expr = "\"amount\" >= 0 AND \"amount\" <= 18446744073709551615";
let drop_sql = emit_alter_column(
"widgets",
"amount",
&ColumnChange::SetCheck {
from: Some(prior_expr.to_string()),
to: None,
},
);
let add_sql = emit_alter_column(
"widgets",
"amount",
&ColumnChange::SetCheck {
from: None,
to: Some(new_expr.to_string()),
},
);
assert!(
drop_sql
.up
.contains("DROP CONSTRAINT \"widgets_amount_check\""),
"AMEND step 1 drops the existing constraint: {}",
drop_sql.up
);
assert!(
add_sql
.up
.contains("ADD CONSTRAINT \"widgets_amount_check\""),
"AMEND step 2 adds the new constraint under the same name: {}",
add_sql.up
);
assert!(
add_sql
.up
.contains("CHECK (\"amount\" >= 0 AND \"amount\" <= 18446744073709551615)"),
"AMEND step 2 carries the new CHECK expression: {}",
add_sql.up
);
}
fn assert_type_change_check_sql_order(before_check: Option<&str>, after_check: Option<&str>) {
let before = applied_schema_with_amount_check("INTEGER", before_check);
let after = applied_schema_with_amount_check("BIGINT", after_check);
let delta = crate::migrate::diff::diff_schemas(
&before,
&after,
crate::migrate::BucketKey {
database: "main".to_string(),
app: "".to_string(),
},
);
let statements = lower_delta(&delta).expect("lower delta");
assert_eq!(statements.len(), 3, "expected 3 migration statements");
assert!(
statements[0]
.up
.contains("DROP CONSTRAINT \"widgets_amount_check\""),
"first statement must drop existing CHECK: {}",
statements[0].up
);
assert!(
statements[1]
.up
.contains("ALTER TABLE \"widgets\" ALTER COLUMN \"amount\" TYPE BIGINT"),
"second statement must alter column type: {}",
statements[1].up
);
assert!(
statements[2]
.up
.contains("ADD CONSTRAINT \"widgets_amount_check\""),
"third statement must add replacement CHECK: {}",
statements[2].up
);
assert!(
statements[2]
.down
.contains("DROP CONSTRAINT \"widgets_amount_check\""),
"third statement rollback must drop the new CHECK: {}",
statements[2].down
);
assert!(
statements[1]
.down
.contains("ALTER TABLE \"widgets\" ALTER COLUMN \"amount\" TYPE INTEGER"),
"second statement rollback must revert type: {}",
statements[1].down
);
let before_expr = before_check.expect("before_check should be Some for this helper");
assert!(
statements[0]
.down
.contains("ADD CONSTRAINT \"widgets_amount_check\""),
"first statement rollback must re-add the original CHECK: {}",
statements[0].down
);
assert!(
statements[0]
.down
.contains(&format!("CHECK ({before_expr})")),
"first statement rollback must restore the original CHECK expression \
`{before_expr}`, got: {}",
statements[0].down
);
assert!(
statements[0].lossy.is_none()
&& statements[1].lossy.is_none()
&& statements[2].lossy.is_none(),
"type-change-with-CHECK rollback is now fully recoverable; lossy: \
{:?} / {:?} / {:?}",
statements[0].lossy,
statements[1].lossy,
statements[2].lossy,
);
}
#[test]
fn alter_column_type_change_orders_drop_then_add_for_unchanged_check() {
assert_type_change_check_sql_order(
Some("\"amount\" >= 0 AND \"amount\" <= 4294967295"),
Some("\"amount\" >= 0 AND \"amount\" <= 4294967295"),
);
}
#[test]
fn alter_column_type_change_orders_drop_then_add_for_changed_check() {
assert_type_change_check_sql_order(
Some("\"amount\" >= 0 AND \"amount\" <= 4294967295"),
Some("\"amount\" >= 0 AND \"amount\" <= 18446744073709551615"),
);
}
#[test]
fn type_change_unchanged_check_down_restores_original_check() {
assert_type_change_check_sql_order(
Some("\"amount\" >= 0 AND \"amount\" <= 4294967295"),
Some("\"amount\" >= 0 AND \"amount\" <= 4294967295"),
);
}
#[test]
fn type_change_changed_check_down_restores_old_not_new_check() {
let before_expr = "\"amount\" >= 0 AND \"amount\" <= 4294967295";
let after_expr = "\"amount\" >= 0 AND \"amount\" <= 18446744073709551615";
let before = applied_schema_with_amount_check("INTEGER", Some(before_expr));
let after = applied_schema_with_amount_check("BIGINT", Some(after_expr));
let delta = crate::migrate::diff::diff_schemas(
&before,
&after,
crate::migrate::BucketKey {
database: "main".to_string(),
app: "".to_string(),
},
);
let statements = lower_delta(&delta).expect("lower delta");
assert!(
statements[0]
.down
.contains(&format!("CHECK ({before_expr})")),
"rollback of the DROP step must restore the OLD CHECK \
({before_expr}), got: {}",
statements[0].down
);
assert!(
!statements[0].down.contains(after_expr),
"rollback of the DROP step must NOT contain the NEW CHECK \
expression ({after_expr}); that would imply the rollback \
left the new expression behind: {}",
statements[0].down
);
}
#[test]
fn type_change_without_prior_check_has_no_check_steps() {
let before = applied_schema_with_amount_check("INTEGER", None);
let after = applied_schema_with_amount_check("BIGINT", None);
let delta = crate::migrate::diff::diff_schemas(
&before,
&after,
crate::migrate::BucketKey {
database: "main".to_string(),
app: "".to_string(),
},
);
let statements = lower_delta(&delta).expect("lower delta");
assert_eq!(statements.len(), 1, "type-only change emits one statement");
assert!(
statements[0]
.up
.contains("ALTER TABLE \"widgets\" ALTER COLUMN \"amount\" TYPE BIGINT"),
"the single statement is the type change: {}",
statements[0].up
);
assert!(
statements[0]
.down
.contains("ALTER TABLE \"widgets\" ALTER COLUMN \"amount\" TYPE INTEGER"),
"rollback reverts the type: {}",
statements[0].down
);
}
#[test]
fn amend_check_only_down_restores_original_expression() {
let before_expr = "\"amount\" >= 0 AND \"amount\" <= 4294967295";
let after_expr = "\"amount\" >= 0 AND \"amount\" <= 18446744073709551615";
let before = applied_schema_with_amount_check("BIGINT", Some(before_expr));
let after = applied_schema_with_amount_check("BIGINT", Some(after_expr));
let delta = crate::migrate::diff::diff_schemas(
&before,
&after,
crate::migrate::BucketKey {
database: "main".to_string(),
app: "".to_string(),
},
);
let statements = lower_delta(&delta).expect("lower delta");
assert_eq!(
statements.len(),
2,
"AMEND emits two statements: {statements:?}"
);
assert!(
statements[0]
.up
.contains("DROP CONSTRAINT \"widgets_amount_check\""),
"up step 0: drop old CHECK: {}",
statements[0].up
);
assert!(
statements[1].up.contains(&format!(
"ADD CONSTRAINT \"widgets_amount_check\" CHECK ({after_expr})"
)),
"up step 1: add new CHECK: {}",
statements[1].up
);
assert!(
statements[1]
.down
.contains("DROP CONSTRAINT \"widgets_amount_check\""),
"down step 1 (composed first in reverse): drops the new CHECK: {}",
statements[1].down
);
assert!(
statements[0].down.contains(&format!(
"ADD CONSTRAINT \"widgets_amount_check\" CHECK ({before_expr})"
)),
"down step 0 (composed last): restores the ORIGINAL CHECK ({before_expr}): {}",
statements[0].down
);
assert!(
statements[0].lossy.is_none() && statements[1].lossy.is_none(),
"AMEND rollback is fully recoverable: {:?} / {:?}",
statements[0].lossy,
statements[1].lossy,
);
}
#[test]
fn pure_drop_check_down_restores_prior() {
let prior_expr = "\"amount\" >= 0 AND \"amount\" <= 4294967295";
let before = applied_schema_with_amount_check("BIGINT", Some(prior_expr));
let after = applied_schema_with_amount_check("BIGINT", None);
let delta = crate::migrate::diff::diff_schemas(
&before,
&after,
crate::migrate::BucketKey {
database: "main".to_string(),
app: "".to_string(),
},
);
let statements = lower_delta(&delta).expect("lower delta");
assert_eq!(statements.len(), 1, "pure DROP emits one statement");
assert!(
statements[0]
.up
.contains("DROP CONSTRAINT \"widgets_amount_check\""),
"up: drop CHECK: {}",
statements[0].up
);
assert!(
statements[0].down.contains(&format!(
"ADD CONSTRAINT \"widgets_amount_check\" CHECK ({prior_expr})"
)),
"down: restore prior CHECK losslessly: {}",
statements[0].down
);
assert!(
statements[0].lossy.is_none(),
"pure DROP rollback is now lossless: {:?}",
statements[0].lossy,
);
}
#[test]
fn pure_add_check_down_drops_without_residue() {
let new_expr = "\"amount\" >= 0 AND \"amount\" <= 4294967295";
let before = applied_schema_with_amount_check("BIGINT", None);
let after = applied_schema_with_amount_check("BIGINT", Some(new_expr));
let delta = crate::migrate::diff::diff_schemas(
&before,
&after,
crate::migrate::BucketKey {
database: "main".to_string(),
app: "".to_string(),
},
);
let statements = lower_delta(&delta).expect("lower delta");
assert_eq!(statements.len(), 1, "pure ADD emits one statement");
assert!(
statements[0].up.contains(&format!(
"ADD CONSTRAINT \"widgets_amount_check\" CHECK ({new_expr})"
)),
"up: install CHECK: {}",
statements[0].up
);
assert!(
statements[0]
.down
.contains("DROP CONSTRAINT \"widgets_amount_check\""),
"down: drop CHECK on rollback: {}",
statements[0].down
);
assert!(
!statements[0].down.contains("ADD CONSTRAINT"),
"pure ADD rollback must not re-install anything: {}",
statements[0].down
);
}
#[test]
fn amend_merged_form_renders_drop_then_add_on_both_sides() {
let sql = emit_alter_column(
"widgets",
"amount",
&ColumnChange::SetCheck {
from: Some("\"amount\" >= 0".to_string()),
to: Some("\"amount\" > 0".to_string()),
},
);
assert!(
sql.up.contains("DROP CONSTRAINT \"widgets_amount_check\""),
"merged AMEND up drops first: {}",
sql.up
);
assert!(
sql.up
.contains("ADD CONSTRAINT \"widgets_amount_check\" CHECK (\"amount\" > 0)"),
"merged AMEND up adds the new: {}",
sql.up
);
assert!(
sql.down
.contains("DROP CONSTRAINT \"widgets_amount_check\""),
"merged AMEND down drops the new first: {}",
sql.down
);
assert!(
sql.down
.contains("ADD CONSTRAINT \"widgets_amount_check\" CHECK (\"amount\" >= 0)"),
"merged AMEND down restores the prior: {}",
sql.down
);
assert!(sql.lossy.is_none(), "merged AMEND rollback is lossless");
}
#[test]
fn alter_column_set_unique_uses_named_key_constraint() {
let sql = emit_alter_column("users", "email", &ColumnChange::SetUnique(true));
assert!(
sql.up
.contains("ADD CONSTRAINT \"users_email_key\" UNIQUE (\"email\")")
);
assert!(sql.down.contains("DROP CONSTRAINT \"users_email_key\""));
}
#[test]
fn alter_column_set_indexed_emits_create_index() {
let sql = emit_alter_column("users", "name", &ColumnChange::SetIndexed(true));
assert!(sql.up.starts_with("CREATE INDEX"));
assert!(sql.up.contains("\"users\""));
assert!(sql.up.contains("(\"name\")"));
}
#[test]
fn alter_column_set_identity_add_emits_add_generated_clause() {
use crate::migrate::schema::IdentityKindSchema;
let sql = emit_alter_column(
"countries",
"id",
&ColumnChange::SetIdentity {
from: None,
to: Some(IdentityKindSchema::ByDefault),
},
);
assert_eq!(
sql.up,
"ALTER TABLE \"countries\" ALTER COLUMN \"id\" ADD GENERATED BY DEFAULT AS IDENTITY;\n\
SELECT setval(pg_get_serial_sequence('countries', 'id'), \
GREATEST(COALESCE((SELECT MAX(\"id\") FROM \"countries\"), 0), 0) + 1, false);"
);
assert_eq!(
sql.down,
"ALTER TABLE \"countries\" ALTER COLUMN \"id\" DROP IDENTITY;"
);
}
#[test]
fn alter_column_set_identity_drop_emits_drop_identity() {
use crate::migrate::schema::IdentityKindSchema;
let sql = emit_alter_column(
"countries",
"id",
&ColumnChange::SetIdentity {
from: Some(IdentityKindSchema::ByDefault),
to: None,
},
);
assert_eq!(
sql.up,
"ALTER TABLE \"countries\" ALTER COLUMN \"id\" DROP IDENTITY;"
);
assert_eq!(
sql.down,
"ALTER TABLE \"countries\" ALTER COLUMN \"id\" ADD GENERATED BY DEFAULT AS IDENTITY;\n\
SELECT setval(pg_get_serial_sequence('countries', 'id'), \
GREATEST(COALESCE((SELECT MAX(\"id\") FROM \"countries\"), 0), 0) + 1, false);"
);
}
#[test]
fn alter_column_set_identity_kind_change_emits_set_generated() {
use crate::migrate::schema::IdentityKindSchema;
let sql = emit_alter_column(
"countries",
"id",
&ColumnChange::SetIdentity {
from: Some(IdentityKindSchema::ByDefault),
to: Some(IdentityKindSchema::Always),
},
);
assert_eq!(
sql.up,
"ALTER TABLE \"countries\" ALTER COLUMN \"id\" SET GENERATED ALWAYS;"
);
assert_eq!(
sql.down,
"ALTER TABLE \"countries\" ALTER COLUMN \"id\" SET GENERATED BY DEFAULT;"
);
}
#[test]
fn add_foreign_key_emits_named_constraint_with_default_restrict() {
let sql = emit_add_foreign_key(
"posts",
"author_id",
&ForeignKeySchema {
deferrable: false,
initially_deferred: false,
on_delete: OnDeleteSchema::Restrict,
ref_column: "id".to_string(),
ref_table: "users".to_string(),
},
);
assert!(sql.up.contains("ADD CONSTRAINT \"posts_author_id_fkey\""));
assert!(sql.up.contains("FOREIGN KEY (\"author_id\")"));
assert!(sql.up.contains("REFERENCES \"users\" (\"id\")"));
assert!(
sql.up.contains("ON DELETE RESTRICT"),
"default cascade must round-trip as RESTRICT, got: {}",
sql.up
);
}
#[test]
fn add_foreign_key_propagates_cascade_kind() {
for (cascade, expected) in [
(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"),
] {
let sql = emit_add_foreign_key(
"posts",
"author_id",
&ForeignKeySchema {
deferrable: false,
initially_deferred: false,
on_delete: cascade,
ref_column: "id".to_string(),
ref_table: "users".to_string(),
},
);
assert!(
sql.up.contains(expected),
"cascade {cascade:?} must emit `{expected}`; got: {}",
sql.up
);
}
}
#[test]
fn render_alter_table_add_deferrable_fk() {
let sql = emit_add_foreign_key(
"posts",
"author_id",
&ForeignKeySchema {
deferrable: true,
initially_deferred: true,
on_delete: OnDeleteSchema::Cascade,
ref_column: "id".to_string(),
ref_table: "users".to_string(),
},
);
assert_eq!(
sql.up,
"ALTER TABLE \"posts\" ADD CONSTRAINT \"posts_author_id_fkey\" \
FOREIGN KEY (\"author_id\") REFERENCES \"users\" (\"id\") \
ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED;"
);
}
#[test]
fn drop_foreign_key_rollback_recreates_constraint_with_cascade() {
let sql = emit_drop_foreign_key(
"posts",
"author_id",
&ForeignKeySchema {
deferrable: false,
initially_deferred: false,
on_delete: OnDeleteSchema::Cascade,
ref_column: "id".to_string(),
ref_table: "users".to_string(),
},
);
assert!(sql.up.contains("DROP CONSTRAINT \"posts_author_id_fkey\""));
assert!(
sql.down.contains("ADD CONSTRAINT \"posts_author_id_fkey\""),
"rollback must recreate the constraint, got: {}",
sql.down
);
assert!(
sql.down.contains("REFERENCES \"users\" (\"id\")"),
"rollback must restore the target, got: {}",
sql.down
);
assert!(
sql.down.contains("ON DELETE CASCADE"),
"rollback must restore the cascade, got: {}",
sql.down
);
assert!(
sql.lossy.is_none(),
"DropForeignKey rollback is structurally clean now; no lossy marker expected"
);
}
#[test]
fn add_index_basic_emits_create_index() {
let i = idx("users_name_idx", "users", &["name"]);
let sql = emit_add_index(&i);
assert_eq!(
sql.up,
"CREATE INDEX \"users_name_idx\" ON \"users\" USING btree (\"name\");"
);
assert_eq!(sql.down, "DROP INDEX \"users_name_idx\";");
}
#[test]
fn add_index_concurrently_marks_out_of_transaction() {
let mut i = idx("events_ts_idx", "events", &["ts"]);
i.requires_out_of_transaction = true;
let sql = emit_add_index(&i);
assert!(sql.up.contains("CREATE INDEX CONCURRENTLY"));
assert!(sql.down.contains("DROP INDEX CONCURRENTLY"));
}
#[test]
fn add_unique_index_uses_unique_keyword() {
let mut i = idx("users_email_uidx", "users", &["email"]);
i.kind = IndexKindSchema::UniqueIndex;
let sql = emit_add_index(&i);
assert!(sql.up.starts_with("CREATE UNIQUE INDEX"));
}
#[test]
fn add_index_with_include_emits_include_clause() {
let mut i = idx("users_email_uidx", "users", &["email"]);
i.kind = IndexKindSchema::UniqueIndex;
i.include = vec!["tenant_id".to_string()];
let sql = emit_add_index(&i);
assert!(sql.up.contains("INCLUDE (\"tenant_id\")"));
}
#[test]
fn add_index_with_predicate_emits_where_clause() {
let mut i = idx("users_email_uidx", "users", &["email"]);
i.kind = IndexKindSchema::UniqueIndex;
i.predicate = Some("deleted_at IS NULL".to_string());
let sql = emit_add_index(&i);
assert!(sql.up.contains("WHERE deleted_at IS NULL"));
}
#[test]
fn add_index_preserves_composite_column_order() {
let i = idx("orgs_a_b_idx", "orgs", &["b_first", "a_second"]);
let sql = emit_add_index(&i);
let bpos = sql.up.find("\"b_first\"").expect("b_first");
let apos = sql.up.find("\"a_second\"").expect("a_second");
assert!(bpos < apos);
}
#[test]
fn add_index_expression_target_uses_double_parens() {
let i = IndexSchema {
target: IndexTargetSchema::Expression("lower(email)".to_string()),
..idx("users_lower_email_idx", "users", &["x"])
};
let sql = emit_add_index(&i);
assert!(sql.up.contains("((lower(email)))"));
}
#[test]
fn add_index_per_column_order_and_nulls_emitted() {
let target = IndexTargetSchema::Columns(vec![IndexColumnSchema {
name: "ts".to_string(),
nulls: IndexNullsOrderSchema::Last,
opclass: None,
order: IndexOrderSchema::Desc,
}]);
let i = IndexSchema {
target,
..idx("events_ts_idx", "events", &["ts"])
};
let sql = emit_add_index(&i);
assert!(sql.up.contains("\"ts\" DESC NULLS LAST"));
}
#[test]
fn add_index_nulls_not_distinct_emits_clause() {
let mut i = idx("users_email_uidx", "users", &["email"]);
i.kind = IndexKindSchema::UniqueIndex;
i.nulls_not_distinct = true;
let sql = emit_add_index(&i);
assert!(sql.up.contains("NULLS NOT DISTINCT"));
}
#[test]
fn drop_index_recreates_in_down_with_full_metadata() {
let mut i = idx("users_email_uidx", "users", &["email"]);
i.kind = IndexKindSchema::UniqueIndex;
i.predicate = Some("deleted_at IS NULL".to_string());
let sql = emit_drop_index(&i);
assert!(sql.up.starts_with("DROP INDEX"));
assert!(sql.down.contains("CREATE UNIQUE INDEX"));
assert!(sql.down.contains("WHERE deleted_at IS NULL"));
assert!(matches!(
sql.lossy.as_ref().map(|w| w.kind),
Some(LossyRollbackKind::DropIndex)
));
}
#[test]
fn add_unique_constraint_emits_alter_table_add_constraint() {
let mut i = idx(
"herd_ranges_herd_id_country_id_season_key",
"herd_ranges",
&["herd_id", "country_id", "season"],
);
i.kind = IndexKindSchema::UniqueConstraint;
let sql = emit_add_index(&i);
assert_eq!(
sql.up,
"ALTER TABLE \"herd_ranges\" ADD CONSTRAINT \
\"herd_ranges_herd_id_country_id_season_key\" UNIQUE \
(\"herd_id\", \"country_id\", \"season\");",
"UniqueConstraint must use ALTER TABLE ADD CONSTRAINT form"
);
assert_eq!(
sql.down,
"ALTER TABLE \"herd_ranges\" DROP CONSTRAINT \
\"herd_ranges_herd_id_country_id_season_key\";",
"UniqueConstraint down must use ALTER TABLE DROP CONSTRAINT form"
);
assert!(
sql.lossy.is_none(),
"AddConstraintUnique forward side is non-lossy"
);
}
#[test]
fn add_unique_constraint_single_column_uses_constraint_form() {
let mut i = idx("t6_simple_unique_email_key", "t6_simple_unique", &["email"]);
i.kind = IndexKindSchema::UniqueConstraint;
let sql = emit_add_index(&i);
assert!(
sql.up.starts_with("ALTER TABLE"),
"single-column UniqueConstraint must use ALTER TABLE form, not CREATE INDEX: {}",
sql.up
);
assert!(
sql.up.contains("ADD CONSTRAINT"),
"single-column UniqueConstraint up must contain ADD CONSTRAINT: {}",
sql.up
);
assert!(
!sql.up.contains("CREATE"),
"single-column UniqueConstraint up must not contain CREATE: {}",
sql.up
);
}
#[test]
fn drop_unique_constraint_uses_alter_table_drop_constraint() {
let mut i = idx(
"elephant_ancestries_elephant_id_ancestor_id_depth_key",
"elephant_ancestries",
&["elephant_id", "ancestor_id", "depth"],
);
i.kind = IndexKindSchema::UniqueConstraint;
let sql = emit_drop_index(&i);
assert_eq!(
sql.up,
"ALTER TABLE \"elephant_ancestries\" DROP CONSTRAINT \
\"elephant_ancestries_elephant_id_ancestor_id_depth_key\";",
"DropConstraintUnique up must use ALTER TABLE DROP CONSTRAINT"
);
assert!(
sql.down.starts_with("ALTER TABLE"),
"DropConstraintUnique down must use ALTER TABLE ADD CONSTRAINT, got: {}",
sql.down
);
assert!(
sql.down.contains("ADD CONSTRAINT"),
"DropConstraintUnique down must contain ADD CONSTRAINT: {}",
sql.down
);
assert!(
!sql.down.contains("CREATE UNIQUE INDEX"),
"DropConstraintUnique down must not use CREATE UNIQUE INDEX: {}",
sql.down
);
assert!(
matches!(
sql.lossy.as_ref().map(|w| w.kind),
Some(LossyRollbackKind::DropIndex)
),
"DropConstraintUnique must carry a lossy marker"
);
}
#[test]
fn field_level_btree_index_emits_create_index_using_btree() {
let i = idx("elephants_herd_id_idx", "elephants", &["herd_id"]);
let sql = emit_add_index(&i);
assert_eq!(
sql.up,
r#"CREATE INDEX "elephants_herd_id_idx" ON "elephants" USING btree ("herd_id");"#,
"field-level BTree index must produce CREATE INDEX … USING btree"
);
assert_eq!(
sql.down, r#"DROP INDEX "elephants_herd_id_idx";"#,
"field-level BTree index down must be a plain DROP INDEX"
);
assert!(sql.lossy.is_none(), "AddIndex is non-lossy forward");
}
#[test]
fn field_level_gin_index_emits_create_index_using_gin() {
let mut i = idx("posts_tags_idx", "posts", &["tags"]);
i.index_type = IndexTypeSchema::Gin;
let sql = emit_add_index(&i);
assert!(
sql.up.contains("USING gin"),
"field-level GIN index must emit USING gin; got: {}",
sql.up
);
assert!(
sql.up.starts_with("CREATE INDEX"),
"field-level GIN index must be a plain CREATE INDEX (non-unique); got: {}",
sql.up
);
}
#[test]
fn unique_constraint_with_opclass_emits_plain_column_names_not_opclass() {
let mut i = idx("users_email_key", "users", &["email"]);
i.kind = IndexKindSchema::UniqueConstraint;
if let IndexTargetSchema::Columns(ref mut cols) = i.target {
cols[0].opclass = Some("text_pattern_ops".to_string());
}
let sql = emit_add_index(&i);
assert!(
!sql.up.contains("text_pattern_ops"),
"UniqueConstraint must not emit opclass in ADD CONSTRAINT column list; got: {}",
sql.up
);
assert!(
sql.up.contains("ADD CONSTRAINT"),
"UniqueConstraint must still emit ADD CONSTRAINT form; got: {}",
sql.up
);
assert_eq!(
sql.up, r#"ALTER TABLE "users" ADD CONSTRAINT "users_email_key" UNIQUE ("email");"#,
"UniqueConstraint with opclass must produce plain-identifier ADD CONSTRAINT"
);
}
#[test]
fn unique_constraint_with_desc_order_emits_plain_column_names_not_order() {
let mut i = idx("orgs_name_key", "orgs", &["name"]);
i.kind = IndexKindSchema::UniqueConstraint;
if let IndexTargetSchema::Columns(ref mut cols) = i.target {
cols[0].order = IndexOrderSchema::Desc;
}
let sql = emit_add_index(&i);
assert!(
!sql.up.contains("DESC"),
"UniqueConstraint must not emit DESC in ADD CONSTRAINT column list; got: {}",
sql.up
);
assert!(
sql.up.contains("ADD CONSTRAINT"),
"UniqueConstraint must still emit ADD CONSTRAINT form; got: {}",
sql.up
);
}
#[test]
fn drop_unique_constraint_recreate_does_not_emit_index_only_modifiers() {
let mut i = idx("widgets_code_key", "widgets", &["code"]);
i.kind = IndexKindSchema::UniqueConstraint;
if let IndexTargetSchema::Columns(ref mut cols) = i.target {
cols[0].opclass = Some("text_pattern_ops".to_string());
cols[0].order = IndexOrderSchema::Desc;
cols[0].nulls = IndexNullsOrderSchema::First;
}
let sql = emit_drop_index(&i);
assert!(
sql.up.contains("DROP CONSTRAINT"),
"DropConstraintUnique up must use DROP CONSTRAINT; got: {}",
sql.up
);
assert!(
!sql.down.contains("text_pattern_ops"),
"DropConstraintUnique recreate must not emit opclass; got: {}",
sql.down
);
assert!(
!sql.down.contains("DESC"),
"DropConstraintUnique recreate must not emit DESC; got: {}",
sql.down
);
assert!(
!sql.down.contains("NULLS FIRST"),
"DropConstraintUnique recreate must not emit NULLS FIRST; got: {}",
sql.down
);
assert!(
sql.down.contains("ADD CONSTRAINT"),
"DropConstraintUnique recreate must use ADD CONSTRAINT form; got: {}",
sql.down
);
}
#[test]
#[should_panic(expected = "PostgreSQL unique indexes are btree-only")]
fn emit_add_index_panics_on_unique_index_with_gist_index_type() {
let mut i = idx("places_loc_uidx", "places", &["loc"]);
i.kind = IndexKindSchema::UniqueIndex;
i.index_type = IndexTypeSchema::Gist;
let _ = emit_add_index(&i);
}
#[test]
#[should_panic(expected = "PostgreSQL unique indexes are btree-only")]
fn emit_add_index_panics_on_unique_index_with_gin_index_type() {
let mut i = idx("profiles_payload_uidx", "profiles", &["payload"]);
i.kind = IndexKindSchema::UniqueIndex;
i.index_type = IndexTypeSchema::Gin;
let _ = emit_add_index(&i);
}
#[test]
#[should_panic(expected = "PostgreSQL unique indexes are btree-only")]
fn emit_add_index_panics_on_unique_index_with_brin_index_type() {
let mut i = idx("events_at_uidx", "events", &["happened_at"]);
i.kind = IndexKindSchema::UniqueIndex;
i.index_type = IndexTypeSchema::Brin;
let _ = emit_add_index(&i);
}
#[test]
#[should_panic(expected = "PostgreSQL unique indexes are btree-only")]
fn emit_add_index_panics_on_unique_index_with_spgist_index_type() {
let mut i = idx("tags_path_uidx", "tags", &["path"]);
i.kind = IndexKindSchema::UniqueIndex;
i.index_type = IndexTypeSchema::Spgist;
let _ = emit_add_index(&i);
}
#[test]
#[should_panic(expected = "PostgreSQL unique indexes are btree-only")]
fn emit_add_index_panics_on_unique_index_with_hash_index_type() {
let mut i = idx("users_slug_uidx", "users", &["slug"]);
i.kind = IndexKindSchema::UniqueIndex;
i.index_type = IndexTypeSchema::Hash;
let _ = emit_add_index(&i);
}
#[test]
fn emit_add_index_accepts_unique_index_with_btree_index_type() {
let mut i = idx("accounts_email_uidx", "accounts", &["email"]);
i.kind = IndexKindSchema::UniqueIndex;
i.index_type = IndexTypeSchema::BTree;
i.predicate = Some("deleted_at IS NULL".to_string());
let sql = emit_add_index(&i);
assert!(
sql.up.contains("CREATE UNIQUE INDEX"),
"btree UniqueIndex must emit CREATE UNIQUE INDEX; got: {}",
sql.up
);
assert!(
sql.up.contains("USING btree"),
"btree UniqueIndex must emit USING btree; got: {}",
sql.up
);
}
#[test]
fn add_enum_emits_create_type_with_quoted_variants() {
let e = EnumSchema {
name: "status".to_string(),
variants: vec!["active".to_string(), "deleted".to_string()],
};
let sql = emit_add_enum(&e);
assert_eq!(
sql.up,
"CREATE TYPE \"status\" AS ENUM ('active', 'deleted');"
);
assert_eq!(sql.down, "DROP TYPE \"status\";");
}
#[test]
fn add_enum_variant_with_apostrophe_quotes_correctly() {
let e = EnumSchema {
name: "title".to_string(),
variants: vec!["it's complicated".to_string()],
};
let sql = emit_add_enum(&e);
assert!(sql.up.contains("'it''s complicated'"));
}
#[test]
fn drop_enum_marks_lossy() {
let sql = emit_drop_enum("status");
assert_eq!(sql.up, "DROP TYPE \"status\";");
assert!(sql.down.contains("LOSSY ROLLBACK"));
assert!(matches!(
sql.lossy.as_ref().map(|w| w.kind),
Some(LossyRollbackKind::DropEnum)
));
}
#[test]
fn add_enum_variant_without_anchor_appends() {
let sql = emit_add_enum_variant("status", "archived", None);
assert_eq!(sql.up, "ALTER TYPE \"status\" ADD VALUE 'archived';");
assert!(sql.down.contains("no `ALTER TYPE ... DROP VALUE`"));
}
#[test]
fn add_enum_variant_with_before_anchor_emits_before_clause() {
let anchor = EnumVariantAnchor {
variant: "deleted".to_string(),
kind: EnumVariantAnchorKind::Before,
};
let sql = emit_add_enum_variant("status", "archived", Some(&anchor));
assert_eq!(
sql.up,
"ALTER TYPE \"status\" ADD VALUE 'archived' BEFORE 'deleted';"
);
}
#[test]
fn add_enum_variant_with_after_anchor_emits_after_clause() {
let anchor = EnumVariantAnchor {
variant: "active".to_string(),
kind: EnumVariantAnchorKind::After,
};
let sql = emit_add_enum_variant("status", "archived", Some(&anchor));
assert_eq!(
sql.up,
"ALTER TYPE \"status\" ADD VALUE 'archived' AFTER 'active';"
);
}
#[test]
fn add_enum_variant_with_apostrophe_anchor_quotes_correctly() {
let anchor = EnumVariantAnchor {
variant: "it's complicated".to_string(),
kind: EnumVariantAnchorKind::Before,
};
let sql = emit_add_enum_variant("title", "settled", Some(&anchor));
assert!(
sql.up.contains("BEFORE 'it''s complicated'"),
"got: {}",
sql.up
);
}
#[test]
fn pk_type_flip_routes_to_t9_error() {
let op = SchemaOperation::PkTypeFlip {
table: "users".to_string(),
from: PkKindSchema::HeerId,
to: PkKindSchema::HeerIdRecencyBiased,
};
let err = lower_operation(&op).expect_err("PkTypeFlip must error");
assert!(matches!(err, SqlEmitError::PkTypeFlipMustRouteToT9 { .. }));
}
#[test]
fn unsupported_operation_propagates_reason() {
let op = SchemaOperation::Unsupported {
reason: "partition method change".to_string(),
};
let err = lower_operation(&op).expect_err("Unsupported must error");
match err {
SqlEmitError::Unsupported { reason } => {
assert_eq!(reason, "partition method change");
}
other => panic!("expected Unsupported, got {other:?}"),
}
}
#[test]
fn lower_delta_returns_empty_for_noop() {
let delta = SchemaDelta {
bucket: BucketKey {
database: "main".to_string(),
app: "".to_string(),
},
operations: Vec::new(),
classification: Classification::NoOp,
};
let out = lower_delta(&delta).expect("ok");
assert!(out.is_empty());
}
#[test]
fn lower_delta_propagates_unsupported_as_hard_error() {
let delta = SchemaDelta {
bucket: BucketKey {
database: "main".to_string(),
app: "".to_string(),
},
operations: vec![SchemaOperation::Unsupported {
reason: "X".to_string(),
}],
classification: Classification::Unsupported {
reason: "X".to_string(),
},
};
let err = lower_delta(&delta).expect_err("must error");
assert!(matches!(err, SqlEmitError::Unsupported { .. }));
}
#[test]
fn lower_delta_propagates_pk_flip_as_hard_error() {
let delta = SchemaDelta {
bucket: BucketKey {
database: "main".to_string(),
app: "".to_string(),
},
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 = lower_delta(&delta).expect_err("must error");
assert!(matches!(err, SqlEmitError::PkTypeFlipMustRouteToT9 { .. }));
}
#[test]
fn same_delta_lowers_byte_identically() {
let mut t = synth_table("users");
t.columns.push(col("email", "TEXT", false));
let delta = SchemaDelta {
bucket: BucketKey {
database: "main".to_string(),
app: "".to_string(),
},
operations: vec![
SchemaOperation::AddTable(t.clone()),
SchemaOperation::AddIndex(idx("users_email_idx", "users", &["email"])),
],
classification: Classification::Additive,
};
let a = lower_delta(&delta).unwrap();
let b = lower_delta(&delta).unwrap();
assert_eq!(a, b);
}
#[test]
fn truncate_constraint_handles_long_names_with_digest() {
let long = format!("{}_a_b_c_d_e_f_g_h_i_j_check", "a".repeat(60));
let truncated = truncate_constraint(long.clone());
assert!(truncated.len() <= 63);
assert_eq!(truncate_constraint(long), truncated);
}
#[test]
fn truncate_constraint_passes_short_names_through() {
let short = "users_email_check".to_string();
assert_eq!(truncate_constraint(short.clone()), short);
}
use crate::migrate::schema::{
ExclusionConstraintSchema, ExclusionElementSchema, GeneratedColumnSchema,
};
fn period_overlap_exclusion() -> ExclusionConstraintSchema {
ExclusionConstraintSchema {
deferrable: false,
elements: vec![
ExclusionElementSchema {
expr: "room_id".to_string(),
with_operator: "=".to_string(),
},
ExclusionElementSchema {
expr: "period".to_string(),
with_operator: "&&".to_string(),
},
],
extension_dependency: None,
initially_deferred: false,
name: "no_overlap".to_string(),
using: "gist".to_string(),
where_clause: None,
}
}
#[test]
fn create_table_inlines_exclusion_constraint() {
let mut t = synth_table("bookings");
t.exclusion_constraints = vec![period_overlap_exclusion()];
let op = emit_add_table(&t);
assert!(op.up.contains(
"CONSTRAINT \"no_overlap\" EXCLUDE USING gist (room_id WITH =, period WITH &&)"
));
let close_paren = op.up.find(");").expect("expected closing paren");
let constraint_idx = op.up.find("CONSTRAINT \"no_overlap\"").unwrap();
assert!(constraint_idx < close_paren);
}
#[test]
fn create_table_emits_exclusion_with_where_and_deferrable() {
let mut t = synth_table("bookings");
let mut excl = period_overlap_exclusion();
excl.where_clause = Some("status = 'confirmed'".to_string());
excl.deferrable = true;
excl.initially_deferred = true;
t.exclusion_constraints = vec![excl];
let op = emit_add_table(&t);
assert!(
op.up.contains("WHERE (status = 'confirmed')"),
"missing WHERE clause: {}",
op.up
);
assert!(
op.up.contains("DEFERRABLE INITIALLY DEFERRED"),
"missing deferrable suffix: {}",
op.up
);
}
#[test]
fn alter_table_add_exclusion_emits_constraint() {
let op = emit_add_exclusion_constraint("bookings", &period_overlap_exclusion());
assert_eq!(
op.up,
"ALTER TABLE \"bookings\" ADD CONSTRAINT \"no_overlap\" \
EXCLUDE USING gist (room_id WITH =, period WITH &&);",
);
assert_eq!(
op.down,
"ALTER TABLE \"bookings\" DROP CONSTRAINT \"no_overlap\";",
);
}
#[test]
fn alter_table_drop_exclusion_round_trips_via_carried_schema() {
let op =
emit_drop_exclusion_constraint("bookings", "no_overlap", &period_overlap_exclusion());
assert_eq!(
op.up,
"ALTER TABLE \"bookings\" DROP CONSTRAINT \"no_overlap\";",
);
assert!(op.down.contains(
"ADD CONSTRAINT \"no_overlap\" EXCLUDE USING gist (room_id WITH =, period WITH &&)"
));
}
#[test]
fn add_column_with_generated_emits_stored_clause() {
let column = ColumnSchema {
generated: Some(GeneratedColumnSchema {
expression: "LOWER(email)".to_string(),
stored: true,
}),
..col("email_lower", "TEXT", true)
};
let op = emit_add_column("users", &column);
assert!(
op.up.contains("GENERATED ALWAYS AS (LOWER(email)) STORED"),
"missing GENERATED clause: {}",
op.up
);
assert!(
!op.up.contains("DEFAULT"),
"generated column should not emit DEFAULT: {}",
op.up
);
}
#[test]
fn create_table_inlines_generated_column() {
let generated = ColumnSchema {
generated: Some(GeneratedColumnSchema {
expression: "LOWER(email)".to_string(),
stored: true,
}),
..col("email_lower", "TEXT", true)
};
let mut t = synth_table("users");
t.columns.push(generated);
let op = emit_add_table(&t);
assert!(
op.up
.contains("\"email_lower\" TEXT GENERATED ALWAYS AS (LOWER(email)) STORED"),
"missing inline GENERATED: {}",
op.up
);
}
#[test]
fn alter_column_change_generated_expression_uses_in_place_form() {
let sql = emit_alter_column(
"users",
"email_lower",
&ColumnChange::SetGenerated {
from: Some(GeneratedColumnSchema {
expression: "LOWER(email)".to_string(),
stored: true,
}),
to: Some(GeneratedColumnSchema {
expression: "LOWER(TRIM(email))".to_string(),
stored: true,
}),
},
);
assert!(
sql.up
.contains("ALTER TABLE \"users\" ALTER COLUMN \"email_lower\" SET EXPRESSION AS (LOWER(TRIM(email)));"),
"expression change must emit SET EXPRESSION AS for PG 17+: {}",
sql.up
);
assert!(
!sql.up.contains("DROP COLUMN"),
"must not emit destructive DROP COLUMN for in-place expression change: {}",
sql.up
);
assert!(
!sql.up.contains("ADD COLUMN"),
"must not emit destructive ADD COLUMN for in-place expression change: {}",
sql.up
);
assert!(
sql.down
.contains("ALTER TABLE \"users\" ALTER COLUMN \"email_lower\" SET EXPRESSION AS (LOWER(email));"),
"down side must restore the prior expression in place: {}",
sql.down
);
assert!(
sql.lossy.is_none(),
"expression change rolls back fully via inverse SET EXPRESSION AS — not lossy"
);
}
#[test]
fn alter_column_drop_generated_emits_drop_expression_lossy() {
let sql = emit_alter_column(
"users",
"email_lower",
&ColumnChange::SetGenerated {
from: Some(GeneratedColumnSchema {
expression: "LOWER(email)".to_string(),
stored: true,
}),
to: None,
},
);
assert_eq!(
sql.up, "ALTER TABLE \"users\" ALTER COLUMN \"email_lower\" DROP EXPRESSION;",
"DROP EXPRESSION should be the single UP statement",
);
assert!(
sql.down.contains("LOSSY ROLLBACK"),
"down side must surface the rollback gap: {}",
sql.down
);
assert!(
sql.lossy
.as_ref()
.is_some_and(|w| matches!(w.kind, LossyRollbackKind::DropColumn)),
"drop generation must mark the rollback as DropColumn-flavoured lossy: {:?}",
sql.lossy
);
}
#[test]
fn alter_column_add_generated_keeps_offline_placeholder() {
let sql = emit_alter_column(
"users",
"email_lower",
&ColumnChange::SetGenerated {
from: None,
to: Some(GeneratedColumnSchema {
expression: "LOWER(email)".to_string(),
stored: true,
}),
},
);
assert!(
sql.up.contains("OfflineOnly"),
"UP must document the offline-only nature: {}",
sql.up
);
assert!(
sql.up.contains("LOWER(email)"),
"UP placeholder must surface the target expression for review: {}",
sql.up
);
assert!(
!sql.up.contains("ALTER TABLE"),
"UP must not emit executable SQL for the add path: {}",
sql.up
);
}
#[test]
fn alter_column_change_generated_expression_lowers_through_diff() {
use crate::migrate::schema::{
AppliedSchema, GeneratedColumnSchema, PkKindSchema, PrimaryKeySchema, TableSchema,
};
use std::collections::BTreeMap;
fn build_schema(expr: &str) -> AppliedSchema {
let mut models = BTreeMap::new();
let table = TableSchema {
app: None,
columns: vec![ColumnSchema {
generated: Some(GeneratedColumnSchema {
expression: expr.to_string(),
stored: true,
}),
nullable: true,
..col("email_lower", "TEXT", true)
}],
exclusion_constraints: Vec::new(),
fts: None,
is_through: false,
moved_from_app: None,
partition: None,
primary_key: PrimaryKeySchema {
columns: Vec::new(),
kind: PkKindSchema::None,
},
rationale: None,
renamed_from: None,
rls_enabled: false,
storage_params: None,
table: "users".to_string(),
table_comment: None,
tablespace: None,
tenant_key: None,
};
models.insert("users".to_string(), table);
AppliedSchema {
djogi_version: String::new(),
enums: BTreeMap::new(),
format_version: super::super::schema::SNAPSHOT_FORMAT_VERSION.to_string(),
generated_at: String::new(),
indexes: Vec::new(),
models,
registered_apps: vec![String::new()],
}
}
let before = build_schema("LOWER(email)");
let after = build_schema("LOWER(TRIM(email))");
let delta = crate::migrate::diff::diff_schemas(
&before,
&after,
crate::migrate::BucketKey {
database: "main".to_string(),
app: String::new(),
},
);
let ops = lower_delta(&delta).expect("lower delta");
assert_eq!(ops.len(), 1, "expected a single AlterColumn statement");
let op = &ops[0];
assert!(
op.up.contains(
"ALTER TABLE \"users\" ALTER COLUMN \"email_lower\" SET EXPRESSION AS (LOWER(TRIM(email)));"
),
"end-to-end emit must use the in-place PG 17+ form: {}",
op.up
);
assert!(
!op.up.contains("DROP COLUMN"),
"no destructive DROP COLUMN: {}",
op.up
);
assert!(
!op.up.contains("ADD COLUMN"),
"no destructive ADD COLUMN: {}",
op.up
);
}
#[test]
fn add_table_emits_ddl_metadata_after_create_table() {
let mut t = synth_table("widgets");
t.table_comment = Some("Widget owner's table".to_string());
t.storage_params = Some("fillfactor=70, autovacuum_enabled=false".to_string());
t.tablespace = Some("fastspace".to_string());
t.columns[1].comment = Some("Human-readable widget name".to_string());
let sql = emit_add_table(&t);
assert!(sql.up.contains("CREATE TABLE \"widgets\""));
assert!(
sql.up
.contains("COMMENT ON TABLE \"widgets\" IS E'Widget owner''s table';")
);
assert!(
sql.up.contains(
"COMMENT ON COLUMN \"widgets\".\"name\" IS E'Human-readable widget name';"
)
);
assert!(
sql.up
.contains("ALTER TABLE \"widgets\" SET (fillfactor=70, autovacuum_enabled=false);")
);
assert!(
sql.up
.contains("ALTER TABLE \"widgets\" SET TABLESPACE \"fastspace\";")
);
}
#[test]
fn table_comment_literal_escapes_backslash_quote_injection_fragments() {
let dangerous = r"ok\'; DROP TABLE audit_log; --";
let op = lower_operation(&SchemaOperation::SetTableComment {
table: "widgets".to_string(),
from: None,
to: Some(dangerous.to_string()),
})
.expect("table comment lower");
assert_eq!(
op.up,
r#"COMMENT ON TABLE "widgets" IS E'ok\\''; DROP TABLE audit_log; --';"#
);
assert_eq!(op.down, r#"COMMENT ON TABLE "widgets" IS NULL;"#);
}
#[test]
fn column_comment_literal_escapes_backslash_quote_injection_fragments() {
let dangerous = r"ok\'; DROP TABLE audit_log; -- owner's note";
let op = emit_alter_column(
"widgets",
"name",
&ColumnChange::SetComment {
from: None,
to: Some(dangerous.to_string()),
},
);
assert_eq!(
op.up,
r#"COMMENT ON COLUMN "widgets"."name" IS E'ok\\''; DROP TABLE audit_log; -- owner''s note';"#
);
assert_eq!(op.down, r#"COMMENT ON COLUMN "widgets"."name" IS NULL;"#);
}
#[test]
fn table_metadata_operations_are_reversible_sql() {
let storage = lower_operation(&SchemaOperation::SetStorageParams {
table: "widgets".to_string(),
from: Some("fillfactor=80".to_string()),
to: Some("fillfactor=70, autovacuum_enabled=false".to_string()),
})
.expect("storage params lower");
assert_eq!(
storage.up,
"ALTER TABLE \"widgets\" RESET (fillfactor);\n\
ALTER TABLE \"widgets\" SET (fillfactor=70, autovacuum_enabled=false);"
);
assert_eq!(
storage.down,
"ALTER TABLE \"widgets\" RESET (fillfactor, autovacuum_enabled);\n\
ALTER TABLE \"widgets\" SET (fillfactor=80);"
);
let tablespace = lower_operation(&SchemaOperation::SetTablespace {
table: "widgets".to_string(),
from: None,
to: Some("fastspace".to_string()),
})
.expect("tablespace lower");
assert_eq!(
tablespace.up,
"ALTER TABLE \"widgets\" SET TABLESPACE \"fastspace\";"
);
assert_eq!(
tablespace.down,
"ALTER TABLE \"widgets\" SET TABLESPACE \"pg_default\";"
);
}
#[test]
fn storage_params_sql_emitter_rejects_injection_fragments() {
for params in [
"fillfactor=70); DROP TABLE x; --",
"fillfactor=70--comment",
"fillfactor=70/*comment*/",
"fillfactor=(70)",
"fillfactor=DROP",
] {
let err = lower_operation(&SchemaOperation::SetStorageParams {
table: "widgets".to_string(),
from: None,
to: Some(params.to_string()),
})
.expect_err("storage params injection fragment rejected");
assert!(
err.to_string().contains("storage_params"),
"diagnostic names storage_params for {params:?}: {err}"
);
}
}
#[test]
fn storage_params_sql_emitter_rejects_duplicate_keys_after_normalization() {
let err = lower_operation(&SchemaOperation::SetStorageParams {
table: "widgets".to_string(),
from: None,
to: Some("fillfactor=70, FillFactor=80".to_string()),
})
.expect_err("duplicate storage params key rejected");
assert!(
err.to_string().contains("duplicate"),
"diagnostic mentions duplicate key: {err}"
);
}
#[test]
fn storage_params_sql_emitter_renders_from_parsed_entries() {
let storage = lower_operation(&SchemaOperation::SetStorageParams {
table: "widgets".to_string(),
from: Some("FillFactor = 80, autovacuum_enabled = true".to_string()),
to: Some("fillfactor = 70".to_string()),
})
.expect("storage params lower");
assert_eq!(
storage.up,
"ALTER TABLE \"widgets\" RESET (fillfactor, autovacuum_enabled);\n\
ALTER TABLE \"widgets\" SET (fillfactor=70);"
);
assert_eq!(
storage.down,
"ALTER TABLE \"widgets\" RESET (fillfactor);\n\
ALTER TABLE \"widgets\" SET (fillfactor=80, autovacuum_enabled=true);"
);
}
#[test]
fn add_table_rejects_invalid_storage_params() {
let mut t = synth_table("widgets");
t.storage_params = Some("fillfactor=70); DROP TABLE x; --".to_string());
let err = lower_operation(&SchemaOperation::AddTable(t))
.expect_err("add table storage params injection rejected");
assert!(
err.to_string().contains("storage_params"),
"diagnostic names storage_params: {err}"
);
}
#[test]
fn add_named_not_null_constraint_emits_pg18_shape() {
let sql = emit_add_named_not_null_constraint(
"bookings",
&NamedNotNullConstraintSchema {
name: "bookings_validity_required".to_string(),
column: "validity".to_string(),
},
);
assert_eq!(
sql.up,
"ALTER TABLE \"bookings\" ADD CONSTRAINT \"bookings_validity_required\" NOT NULL \"validity\";"
);
assert_eq!(
sql.down,
"ALTER TABLE \"bookings\" DROP CONSTRAINT \"bookings_validity_required\";"
);
}
#[test]
fn drop_named_not_null_constraint_readds_valid_pg18_shape() {
let sql = emit_drop_named_not_null_constraint(
"bookings",
&NamedNotNullConstraintSchema {
name: "bookings_validity_required".to_string(),
column: "validity".to_string(),
},
);
assert_eq!(
sql.up,
"ALTER TABLE \"bookings\" DROP CONSTRAINT \"bookings_validity_required\";"
);
assert_eq!(
sql.down,
"ALTER TABLE \"bookings\" ADD CONSTRAINT \"bookings_validity_required\" NOT NULL \"validity\";"
);
}
#[test]
fn add_temporal_primary_key_constraint_emits_without_overlaps() {
let sql = emit_add_temporal_primary_key_constraint(
"employee_assignments",
&TemporalPrimaryKeyConstraintSchema {
name: "employee_assignments_pk".to_string(),
columns: vec!["employee_id".to_string()],
without_overlaps_column: "validity".to_string(),
include: vec!["role".to_string()],
},
);
assert_eq!(
sql.up,
"ALTER TABLE \"employee_assignments\" ADD CONSTRAINT \"employee_assignments_pk\" PRIMARY KEY (\"employee_id\", \"validity\" WITHOUT OVERLAPS) INCLUDE (\"role\");"
);
assert_eq!(
sql.down,
"ALTER TABLE \"employee_assignments\" DROP CONSTRAINT \"employee_assignments_pk\";"
);
}
#[test]
fn drop_temporal_primary_key_constraint_readds_without_overlaps() {
let sql = emit_drop_temporal_primary_key_constraint(
"employee_assignments",
&TemporalPrimaryKeyConstraintSchema {
name: "employee_assignments_pk".to_string(),
columns: vec!["employee_id".to_string()],
without_overlaps_column: "validity".to_string(),
include: vec!["role".to_string()],
},
);
assert_eq!(
sql.up,
"ALTER TABLE \"employee_assignments\" DROP CONSTRAINT \"employee_assignments_pk\";"
);
assert_eq!(
sql.down,
"ALTER TABLE \"employee_assignments\" ADD CONSTRAINT \"employee_assignments_pk\" PRIMARY KEY (\"employee_id\", \"validity\" WITHOUT OVERLAPS) INCLUDE (\"role\");"
);
}
#[test]
fn add_period_foreign_key_constraint_emits_pg18_valid_temporal_fk_clauses() {
let sql = emit_add_period_foreign_key_constraint(
"bookings",
&PeriodForeignKeyConstraintSchema {
name: "bookings_room_validity_fkey".to_string(),
columns: vec!["room_id".to_string()],
period_column: "booking_period".to_string(),
ref_table: "room_availability".to_string(),
ref_columns: vec!["room_id".to_string()],
ref_period_column: "availability_period".to_string(),
deferrable: true,
initially_deferred: true,
enforced: false,
},
);
assert_eq!(
sql.up,
"ALTER TABLE \"bookings\" ADD CONSTRAINT \"bookings_room_validity_fkey\" FOREIGN KEY (\"room_id\", PERIOD \"booking_period\") REFERENCES \"room_availability\" (\"room_id\", PERIOD \"availability_period\") DEFERRABLE INITIALLY DEFERRED NOT ENFORCED;"
);
assert_eq!(
sql.down,
"ALTER TABLE \"bookings\" DROP CONSTRAINT \"bookings_room_validity_fkey\";"
);
}
#[test]
fn drop_period_foreign_key_constraint_readds_pg18_valid_temporal_fk_clauses() {
let sql = emit_drop_period_foreign_key_constraint(
"bookings",
&PeriodForeignKeyConstraintSchema {
name: "bookings_room_validity_fkey".to_string(),
columns: vec!["room_id".to_string()],
period_column: "booking_period".to_string(),
ref_table: "room_availability".to_string(),
ref_columns: vec!["room_id".to_string()],
ref_period_column: "availability_period".to_string(),
deferrable: true,
initially_deferred: true,
enforced: false,
},
);
assert_eq!(
sql.up,
"ALTER TABLE \"bookings\" DROP CONSTRAINT \"bookings_room_validity_fkey\";"
);
assert_eq!(
sql.down,
"ALTER TABLE \"bookings\" ADD CONSTRAINT \"bookings_room_validity_fkey\" FOREIGN KEY (\"room_id\", PERIOD \"booking_period\") REFERENCES \"room_availability\" (\"room_id\", PERIOD \"availability_period\") DEFERRABLE INITIALLY DEFERRED NOT ENFORCED;"
);
}
}