use std::collections::{BTreeMap, BTreeSet};
use std::fmt::Write as _;
use autumn_schema_core::{
Backend, CheckConstraint, Column, ColumnDefault, ColumnType, ForeignKey, ForeignKeyAction,
IdKind, Index, SerialKind, SqliteAffinity, Table,
};
use crate::schema::parse::ParsedSchema;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MigrationPlan {
pub backend: Backend,
pub changes: Vec<SchemaChange>,
}
impl MigrationPlan {
#[must_use]
pub const fn is_empty(&self) -> bool {
self.changes.is_empty()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SchemaChange {
RenameTable {
from: String,
to: String,
},
RenameColumn {
table: String,
from: String,
to: String,
},
RenameIndex {
table: String,
from: String,
index: Index,
},
RenameConflict {
table: String,
reason: String,
},
CreateTable(Table),
DropTable(Table),
AddColumn {
table: String,
column: Column,
},
DropColumn {
table: String,
column: Column,
},
AlterColumnType {
table: String,
column: String,
from: ColumnType,
to: ColumnType,
},
SetNotNull {
table: String,
column: String,
},
DropNotNull {
table: String,
column: String,
},
SetDefault {
table: String,
column: String,
to: ColumnDefault,
from: Option<ColumnDefault>,
},
#[allow(dead_code)]
AddForeignKey {
table: String,
column: String,
foreign_key: autumn_schema_core::ForeignKey,
},
AddIndex {
table: String,
index: Index,
},
DropIndex {
table: String,
index: Index,
},
AddCheck {
table: String,
check: CheckConstraint,
},
PrimaryKeyChange {
table: String,
},
ForeignKeyChange {
table: String,
column: String,
from: String,
to: String,
},
IdentityChange {
table: String,
column: String,
},
DropTableBlockedByInboundFk {
table: String,
referencing_table: String,
referencing_column: String,
},
AlterColumnTypeBlockedByFk {
table: String,
column: String,
},
AddForeignKeyToExistingColumn {
table: String,
column: String,
},
CreateTableBlockedBySkippedField {
table: String,
fields: Vec<String>,
},
}
#[derive(Debug, Clone, Copy, Default)]
pub struct DiffOptions {
pub allow_destructive: bool,
pub definitions_authoritative: bool,
}
#[derive(Debug, thiserror::Error)]
pub enum DiffError {
#[error(
"refusing to emit a destructive migration: {summary}. \
Re-run with --allow-destructive to generate it anyway."
)]
Destructive {
summary: String,
ops: Vec<DestructiveOp>,
},
#[error("cannot apply #[renamed_from] on table `{table}`: {reason}")]
RenameConflict {
table: String,
reason: String,
},
#[error(
"ambiguous change on table `{table}`: column(s) [{}] disappeared and [{}] appeared. \
If this is a rename, put #[renamed_from] on the new field \
(for example #[renamed_from(\"old_name\")]); \
refusing to emit a drop+add. Re-run with --allow-destructive to treat them \
as independent drop/add.",
.dropped.join(", "),
.added.join(", ")
)]
PossibleRename {
table: String,
dropped: Vec<String>,
added: Vec<String>,
},
#[error("primary-key change on table `{table}` is not supported in this slice")]
PrimaryKeyChange {
table: String,
},
#[error(
"foreign-key change on `{table}.{column}` is not supported: the baseline has \
`REFERENCES {from}`, the model has `REFERENCES {to}`. The engine cannot drop \
the `{table}_{column}_fkey` constraint before it adds the new one. If the \
database is correct, change the model to agree with it (for example \
`#[references(on_delete = \"cascade\")]`). If the model is correct, write a \
manual migration (`autumn generate migration`), apply it, then run \
`autumn schema pull`."
)]
ForeignKeyChange {
table: String,
column: String,
from: String,
to: String,
},
#[error(
"identity-generation change on `{table}.{column}` is not supported in this slice: \
changing or dropping a `GENERATED … AS IDENTITY` clause requires an \
`ADD`/`DROP`/`SET GENERATED` migration this engine does not auto-emit."
)]
IdentityChange {
table: String,
column: String,
},
#[error(
"cannot drop table `{table}`: `{referencing_table}.{referencing_column}` \
has a foreign key referencing it. Dropping `{table}` would violate that constraint, \
and this engine has no way to drop the inbound foreign key first. Drop or retarget \
`{referencing_table}.{referencing_column}` first. (--allow-destructive does not override \
this: it authorizes losing this table's data, not breaking another table's integrity.)"
)]
DropTableInboundReference {
table: String,
referencing_table: String,
referencing_column: String,
},
#[error(
"non-implicit type conversion on `{table}.{column}` from {from} to {to} requires a manual \
migration with a USING clause — not supported in this slice"
)]
NonImplicitTypeConversion {
table: String,
column: String,
from: String,
to: String,
},
#[error(
"cannot add required column `{table}.{column}`: a NOT NULL column without a default \
fails on a table that already has rows. Add a default (`#[default(...)]`) or make the \
column nullable (`Option<...>`)."
)]
RequiredColumnWithoutDefault {
table: String,
column: String,
},
#[error(
"cannot set `{table}.{column}` NOT NULL: existing NULL rows would fail the constraint. \
Backfill the column and apply the change manually, or keep it nullable (`Option<...>`)."
)]
SetNotNullRequiresBackfill {
table: String,
column: String,
},
#[error(
"cannot add unique index `{index}` on `{table}`: existing rows may contain duplicate \
values that would fail `CREATE UNIQUE INDEX`. Resolve duplicates and apply this \
manually, or drop the uniqueness requirement."
)]
UniqueIndexRequiresDedup {
table: String,
index: String,
},
#[error(
"cannot change the type of `{table}.{column}`: it participates in a foreign-key \
constraint, which this engine cannot drop and recreate. Change the type via a manual \
migration that drops and recreates the foreign key."
)]
TypeChangeOnForeignKeyColumn {
table: String,
column: String,
},
#[error(
"cannot add a foreign key to the pre-existing column `{table}.{column}`: the offline \
snapshot cannot confirm whether the database already has a constraint for it (e.g. a \
generated association foreign key), and adding a duplicate `{table}_{column}_fkey` would \
fail. Add the foreign key via a manual migration, or re-snapshot from an authoritative \
source."
)]
AddForeignKeyToPreexistingColumn {
table: String,
column: String,
},
#[error(
"generated constraint/index name `{name}` is emitted more than once, or exceeds \
PostgreSQL's 63-byte identifier limit (colliding with another generated name after \
truncation to 63 bytes); rename the offending field or shorten the table/column names."
)]
GeneratedIdentifierTooLong {
name: String,
},
#[error(
"cannot generate `CREATE TABLE {table}`: the model has field(s) the schema parser \
could not represent ({}), so the generated DDL would omit those column(s) and the \
application would query a missing column at runtime. Write this table's migration \
manually.",
.fields.join(", ")
)]
CreateTableWithSkippedField {
table: String,
fields: Vec<String>,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DestructiveOp {
Table(String),
Column {
table: String,
column: String,
},
}
#[derive(Debug, thiserror::Error)]
pub enum EmitError {
#[error("change kind `{kind}` is not renderable on {backend:?} yet (slice 5 owns full SQLite)")]
UnsupportedOnBackend {
kind: &'static str,
backend: Backend,
},
#[error(
"cannot order the new tables for creation: a foreign-key dependency cycle exists among \
[{}]. Inline-FK cycles are not supported in this slice.",
.tables.join(", ")
)]
CyclicTableDependencies {
tables: Vec<String>,
},
#[error("cannot rebuild SQLite table `{table}` for change `{kind}`: {reason}")]
SqliteRebuildUnsupported {
table: String,
kind: &'static str,
reason: String,
},
}
#[must_use]
pub fn diff_schema(baseline: &[Table], desired: &ParsedSchema, opts: DiffOptions) -> MigrationPlan {
let backend = plan_backend(baseline, desired);
let mut changes = crate::schema::rename::rename_changes(baseline, desired);
let renamed = crate::schema::rename::renamed_baseline(baseline, &changes);
let baseline = renamed.as_slice();
let baseline_by_name: BTreeMap<&str, &Table> =
baseline.iter().map(|t| (t.name.as_str(), t)).collect();
let desired_by_name: BTreeMap<&str, &Table> = desired
.tables
.iter()
.map(|t| (t.name.as_str(), t))
.collect();
let mut names: Vec<&str> = baseline_by_name
.keys()
.chain(desired_by_name.keys())
.copied()
.collect();
names.sort_unstable();
names.dedup();
for name in names {
match (baseline_by_name.get(name), desired_by_name.get(name)) {
(Some(base), Some(want)) => {
if want.managed {
diff_table(base, want, desired, opts, backend, &mut changes);
}
}
(None, Some(want)) => {
if want.managed {
let skipped = skipped_columns(&want.name, desired);
if skipped.is_empty() {
changes.push(SchemaChange::CreateTable((*want).clone()));
} else {
changes.push(SchemaChange::CreateTableBlockedBySkippedField {
table: want.name.clone(),
fields: skipped.into_iter().collect(),
});
}
}
}
(Some(base), None) => {
if base.managed {
changes.push(SchemaChange::DropTable((*base).clone()));
}
}
(None, None) => unreachable!("name came from one of the two maps"),
}
}
detect_inbound_fk_blocks(baseline, desired, &mut changes);
detect_fk_type_change_blocks(baseline, desired, &mut changes);
MigrationPlan { backend, changes }
}
fn detect_inbound_fk_blocks(
baseline: &[Table],
desired: &ParsedSchema,
changes: &mut Vec<SchemaChange>,
) {
let dropped: BTreeSet<&str> = changes
.iter()
.filter_map(|c| match c {
SchemaChange::DropTable(t) => Some(t.name.as_str()),
_ => None,
})
.collect();
if dropped.is_empty() {
return;
}
let dropped_columns: BTreeSet<(&str, &str)> = changes
.iter()
.filter_map(|c| match c {
SchemaChange::DropColumn { table, column } => {
Some((table.as_str(), column.name.as_str()))
}
_ => None,
})
.collect();
let mut blocks: BTreeSet<(String, String, String)> = BTreeSet::new();
for table in baseline {
if dropped.contains(table.name.as_str()) {
continue;
}
for column in &table.columns {
if dropped_columns.contains(&(table.name.as_str(), column.name.as_str())) {
continue;
}
if let Some(fk) = &column.references
&& dropped.contains(fk.table.as_str())
{
blocks.insert((fk.table.clone(), table.name.clone(), column.name.clone()));
}
}
}
for table in &desired.tables {
if !table.managed {
continue;
}
for column in &table.columns {
if let Some(fk) = &column.references
&& dropped.contains(fk.table.as_str())
{
blocks.insert((fk.table.clone(), table.name.clone(), column.name.clone()));
}
}
}
changes.extend(
blocks
.into_iter()
.map(|(table, referencing_table, referencing_column)| {
SchemaChange::DropTableBlockedByInboundFk {
table,
referencing_table,
referencing_column,
}
}),
);
}
fn detect_fk_type_change_blocks(
baseline: &[Table],
desired: &ParsedSchema,
changes: &mut Vec<SchemaChange>,
) {
let type_changes: BTreeSet<(&str, &str)> = changes
.iter()
.filter_map(|c| match c {
SchemaChange::AlterColumnType { table, column, .. } => {
Some((table.as_str(), column.as_str()))
}
_ => None,
})
.collect();
if type_changes.is_empty() {
return;
}
let mut blocks: BTreeSet<(String, String)> = BTreeSet::new();
for &(table, column) in &type_changes {
if column_participates_in_fk(table, column, baseline, desired) {
blocks.insert((table.to_owned(), column.to_owned()));
}
}
changes.extend(
blocks
.into_iter()
.map(|(table, column)| SchemaChange::AlterColumnTypeBlockedByFk { table, column }),
);
}
fn column_participates_in_fk(
table: &str,
column: &str,
baseline: &[Table],
desired: &ParsedSchema,
) -> bool {
let is_referencing = baseline
.iter()
.filter(|t| t.name == table)
.flat_map(|t| &t.columns)
.chain(
desired
.tables
.iter()
.filter(|t| t.managed && t.name == table)
.flat_map(|t| &t.columns),
)
.any(|c| c.name == column && c.references.is_some());
if is_referencing {
return true;
}
baseline
.iter()
.flat_map(|t| &t.columns)
.chain(
desired
.tables
.iter()
.filter(|t| t.managed)
.flat_map(|t| &t.columns),
)
.any(|c| {
c.references
.as_ref()
.is_some_and(|fk| fk.table == table && fk.column == column)
})
}
fn diff_table(
base: &Table,
want: &Table,
desired: &ParsedSchema,
opts: DiffOptions,
backend: Backend,
changes: &mut Vec<SchemaChange>,
) {
if base.primary_key != want.primary_key || serial_kinds_conflict(base, want) {
changes.push(SchemaChange::PrimaryKeyChange {
table: want.name.clone(),
});
return;
}
let base_cols: BTreeMap<&str, &Column> =
base.columns.iter().map(|c| (c.name.as_str(), c)).collect();
let want_col_names: std::collections::BTreeSet<&str> =
want.columns.iter().map(|c| c.name.as_str()).collect();
let skipped = skipped_columns(&want.name, desired);
for want_col in &want.columns {
match base_cols.get(want_col.name.as_str()) {
None => changes.push(SchemaChange::AddColumn {
table: want.name.clone(),
column: want_col.clone(),
}),
Some(base_col) => diff_column(&want.name, base_col, want_col, opts, backend, changes),
}
}
for base_col in &base.columns {
if !want_col_names.contains(base_col.name.as_str())
&& !skipped.contains(base_col.name.as_str())
{
changes.push(SchemaChange::DropColumn {
table: want.name.clone(),
column: base_col.clone(),
});
}
}
diff_indexes(&want.name, base, want, &skipped, opts, backend, changes);
diff_checks(&want.name, base, want, changes);
}
fn column_types_equivalent(base: &ColumnType, want: &ColumnType, backend: Backend) -> bool {
if base == want {
return true;
}
if matches!(
(base, want),
(ColumnType::Decimal { .. }, ColumnType::Decimal { .. })
) {
return false;
}
if matches!(
(base, want),
(ColumnType::Attachment, ColumnType::Json) | (ColumnType::Json, ColumnType::Attachment)
) {
return true;
}
if backend != Backend::Sqlite {
return false;
}
if matches!(base, ColumnType::Opaque { .. }) || matches!(want, ColumnType::Opaque { .. }) {
return false;
}
let base_class = base.sqlite_affinity();
base_class == want.sqlite_affinity() && base_class != SqliteAffinity::Numeric
}
fn diff_column(
table: &str,
base: &Column,
want: &Column,
opts: DiffOptions,
backend: Backend,
changes: &mut Vec<SchemaChange>,
) {
if !column_types_equivalent(&base.ty, &want.ty, backend) {
changes.push(SchemaChange::AlterColumnType {
table: table.to_owned(),
column: want.name.clone(),
from: base.ty.clone(),
to: want.ty.clone(),
});
}
match (base.nullable, want.nullable) {
(true, false) => changes.push(SchemaChange::SetNotNull {
table: table.to_owned(),
column: want.name.clone(),
}),
(false, true) => changes.push(SchemaChange::DropNotNull {
table: table.to_owned(),
column: want.name.clone(),
}),
_ => {}
}
if let Some(to) = &want.default
&& base.default.as_ref() != Some(to)
{
changes.push(SchemaChange::SetDefault {
table: table.to_owned(),
column: want.name.clone(),
to: to.clone(),
from: base.default.clone(),
});
}
if let Some(fk) = &want.references {
match &base.references {
None => changes.push(SchemaChange::AddForeignKeyToExistingColumn {
table: table.to_owned(),
column: want.name.clone(),
}),
Some(existing) if existing == fk => {}
Some(existing) => changes.push(SchemaChange::ForeignKeyChange {
table: table.to_owned(),
column: want.name.clone(),
from: describe_foreign_key(existing),
to: describe_foreign_key(fk),
}),
}
}
if opts.definitions_authoritative && base.identity != want.identity {
changes.push(SchemaChange::IdentityChange {
table: table.to_owned(),
column: want.name.clone(),
});
}
}
fn indexes_equivalent(a: &Index, b: &Index) -> bool {
if a.definition.is_some() || b.definition.is_some() {
a.definition == b.definition && a.unique == b.unique
} else {
a.columns == b.columns && a.unique == b.unique
}
}
pub fn index_depends_on_column(index: &Index, col: &str, backend: Backend) -> bool {
if index.columns.iter().any(|c| c == col) {
return true;
}
if backend == Backend::Sqlite
&& let Some(def) = &index.definition
{
return definition_references_column(def, col);
}
false
}
fn definition_references_column(definition: &str, col: &str) -> bool {
if col.is_empty() {
return false;
}
let hay = definition.to_ascii_lowercase();
let needle = col.to_ascii_lowercase();
let hb = hay.as_bytes();
let is_word = |b: u8| b.is_ascii_alphanumeric() || b == b'_';
let mut from = 0;
while let Some(rel) = hay[from..].find(&needle) {
let start = from + rel;
let end = start + needle.len();
let before_ok = start == 0 || !is_word(hb[start - 1]);
let after_ok = end == hb.len() || !is_word(hb[end]);
if before_ok && after_ok {
return true;
}
from = start + 1;
}
false
}
fn baseline_unique_index_covers(base: &Table, want_columns: &[String]) -> bool {
let want_set: BTreeSet<&str> = want_columns.iter().map(String::as_str).collect();
if want_columns.len() == 1
&& base
.columns
.iter()
.any(|c| c.unique && c.name == want_columns[0])
{
return true;
}
base.indexes.iter().any(|idx| {
if !idx.unique || idx.is_partial {
return false;
}
let key: BTreeSet<&str> = if !idx.key_columns.is_empty() {
idx.key_columns.iter().map(String::as_str).collect()
} else if idx.definition.is_none() {
idx.columns.iter().map(String::as_str).collect()
} else {
return false;
};
key == want_set
})
}
fn baseline_satisfies_desired_unique(base: &Table, want_idx: &Index) -> bool {
want_idx.unique && baseline_unique_index_covers(base, &want_idx.columns)
}
fn full_unique_key_columns(idx: &Index) -> Option<BTreeSet<&str>> {
if !idx.unique || idx.is_partial {
return None;
}
if !idx.key_columns.is_empty() {
Some(idx.key_columns.iter().map(String::as_str).collect())
} else if idx.definition.is_none() {
Some(idx.columns.iter().map(String::as_str).collect())
} else {
None
}
}
fn base_index_covers_suppressed_model_unique(base: &Table, base_idx: &Index, want: &Table) -> bool {
let Some(base_key) = full_unique_key_columns(base_idx) else {
return false;
};
want.indexes.iter().any(|w| {
w.unique
&& !base.indexes.iter().any(|b| b.name == w.name)
&& full_unique_key_columns(w).is_some_and(|wk| wk == base_key)
})
}
fn diff_indexes(
table: &str,
base: &Table,
want: &Table,
skipped: &BTreeSet<String>,
opts: DiffOptions,
backend: Backend,
changes: &mut Vec<SchemaChange>,
) {
let base_by_name: BTreeMap<&str, &Index> =
base.indexes.iter().map(|i| (i.name.as_str(), i)).collect();
let want_by_name: BTreeMap<&str, &Index> =
want.indexes.iter().map(|i| (i.name.as_str(), i)).collect();
let want_col_names: BTreeSet<&str> = want.columns.iter().map(|c| c.name.as_str()).collect();
let dropped_columns: Vec<&str> = base
.columns
.iter()
.map(|c| c.name.as_str())
.filter(|n| !want_col_names.contains(n) && !skipped.contains(*n))
.collect();
for want_idx in &want.indexes {
match base_by_name.get(want_idx.name.as_str()) {
None => {
if !opts.definitions_authoritative
&& baseline_satisfies_desired_unique(base, want_idx)
{
continue;
}
changes.push(SchemaChange::AddIndex {
table: table.to_owned(),
index: want_idx.clone(),
});
}
Some(base_idx) => {
let involves_definition =
base_idx.definition.is_some() || want_idx.definition.is_some();
if involves_definition && !opts.definitions_authoritative {
if want_idx.unique && !baseline_satisfies_desired_unique(base, want_idx) {
changes.push(SchemaChange::AddIndex {
table: table.to_owned(),
index: want_idx.clone(),
});
}
continue;
}
if !indexes_equivalent(base_idx, want_idx) {
changes.push(SchemaChange::DropIndex {
table: table.to_owned(),
index: (*base_idx).clone(),
});
changes.push(SchemaChange::AddIndex {
table: table.to_owned(),
index: want_idx.clone(),
});
}
}
}
}
for base_idx in &base.indexes {
if want_by_name.contains_key(base_idx.name.as_str())
|| base_idx.columns.iter().any(|c| skipped.contains(c))
{
continue;
}
let orphaned_by_column_drop = backend == Backend::Sqlite
&& dropped_columns
.iter()
.any(|c| index_depends_on_column(base_idx, c, backend));
let retain = !opts.definitions_authoritative
&& !orphaned_by_column_drop
&& (base_idx.definition.is_some()
|| base_index_covers_suppressed_model_unique(base, base_idx, want));
if retain {
continue;
}
changes.push(SchemaChange::DropIndex {
table: table.to_owned(),
index: base_idx.clone(),
});
}
}
fn diff_checks(table: &str, base: &Table, want: &Table, changes: &mut Vec<SchemaChange>) {
for want_check in &want.checks {
if !base.checks.iter().any(|c| c == want_check) {
changes.push(SchemaChange::AddCheck {
table: table.to_owned(),
check: want_check.clone(),
});
}
}
}
fn skipped_columns(table: &str, desired: &ParsedSchema) -> std::collections::BTreeSet<String> {
desired
.diagnostics
.iter()
.filter(|d| d.table == table)
.map(|d| d.field.clone())
.collect()
}
fn plan_backend(baseline: &[Table], desired: &ParsedSchema) -> Backend {
desired
.tables
.first()
.or_else(|| baseline.first())
.map_or(Backend::Postgres, |t| t.backend)
}
fn foreign_key_change_error(plan: &MigrationPlan) -> Option<DiffError> {
plan.changes.iter().find_map(|c| match c {
SchemaChange::ForeignKeyChange {
table,
column,
from,
to,
} => Some(DiffError::ForeignKeyChange {
table: table.clone(),
column: column.clone(),
from: from.clone(),
to: to.clone(),
}),
_ => None,
})
}
pub fn guard_plan(plan: &MigrationPlan, opts: DiffOptions) -> Result<(), DiffError> {
find_rename_conflict(plan).map_or(Ok(()), Err)?;
if let Some(table) = plan.changes.iter().find_map(|c| match c {
SchemaChange::PrimaryKeyChange { table } => Some(table.clone()),
_ => None,
}) {
return Err(DiffError::PrimaryKeyChange { table });
}
if let Some(err) = foreign_key_change_error(plan) {
return Err(err);
}
if let Some(err) = find_identity_change_block(plan) {
return Err(err);
}
if let Some((table, column)) = plan.changes.iter().find_map(|c| match c {
SchemaChange::AddForeignKeyToExistingColumn { table, column } => {
Some((table.clone(), column.clone()))
}
_ => None,
}) {
return Err(DiffError::AddForeignKeyToPreexistingColumn { table, column });
}
if let Some((table, fields)) = plan.changes.iter().find_map(|c| match c {
SchemaChange::CreateTableBlockedBySkippedField { table, fields } => {
Some((table.clone(), fields.clone()))
}
_ => None,
}) {
return Err(DiffError::CreateTableWithSkippedField { table, fields });
}
if let Some(err) = find_inbound_fk_block(plan) {
return Err(err);
}
if let Some(err) = find_non_implicit_conversion(plan) {
return Err(err);
}
if let Some(err) = find_fk_type_change_block(plan) {
return Err(err);
}
if let Some(err) = find_identifier_limit_violation(plan) {
return Err(err);
}
if !opts.allow_destructive
&& let Some(err) = find_possible_rename(plan)
{
return Err(err);
}
if let Some((table, column)) = plan.changes.iter().find_map(|c| match c {
SchemaChange::AddColumn { table, column }
if !column.nullable && column.default.is_none() =>
{
Some((table.clone(), column.name.clone()))
}
_ => None,
}) {
return Err(DiffError::RequiredColumnWithoutDefault { table, column });
}
if let Some((table, column)) = plan.changes.iter().find_map(|c| match c {
SchemaChange::SetNotNull { table, column } => Some((table.clone(), column.clone())),
_ => None,
}) {
return Err(DiffError::SetNotNullRequiresBackfill { table, column });
}
if let Some(err) = find_unique_index_requires_dedup(plan) {
return Err(err);
}
if opts.allow_destructive {
return Ok(());
}
let destructive_ops: Vec<DestructiveOp> = plan
.changes
.iter()
.filter_map(|c| match c {
SchemaChange::DropTable(t) => Some(DestructiveOp::Table(t.name.clone())),
SchemaChange::DropColumn { table, column } => Some(DestructiveOp::Column {
table: table.clone(),
column: column.name.clone(),
}),
_ => None,
})
.collect();
if !destructive_ops.is_empty() {
let summary = destructive_ops
.iter()
.map(|op| match op {
DestructiveOp::Table(t) => format!("DROP TABLE {t}"),
DestructiveOp::Column { table, column } => {
format!("DROP COLUMN {table}.{column}")
}
})
.collect::<Vec<_>>()
.join("; ");
return Err(DiffError::Destructive {
summary,
ops: destructive_ops,
});
}
Ok(())
}
fn find_rename_conflict(plan: &MigrationPlan) -> Option<DiffError> {
plan.changes.iter().find_map(|c| match c {
SchemaChange::RenameConflict { table, reason } => Some(DiffError::RenameConflict {
table: table.clone(),
reason: reason.clone(),
}),
_ => None,
})
}
fn find_identity_change_block(plan: &MigrationPlan) -> Option<DiffError> {
plan.changes.iter().find_map(|c| match c {
SchemaChange::IdentityChange { table, column } => Some(DiffError::IdentityChange {
table: table.clone(),
column: column.clone(),
}),
_ => None,
})
}
fn find_inbound_fk_block(plan: &MigrationPlan) -> Option<DiffError> {
plan.changes.iter().find_map(|c| match c {
SchemaChange::DropTableBlockedByInboundFk {
table,
referencing_table,
referencing_column,
} => Some(DiffError::DropTableInboundReference {
table: table.clone(),
referencing_table: referencing_table.clone(),
referencing_column: referencing_column.clone(),
}),
_ => None,
})
}
fn find_fk_type_change_block(plan: &MigrationPlan) -> Option<DiffError> {
if plan.backend != Backend::Postgres {
return None;
}
plan.changes.iter().find_map(|c| match c {
SchemaChange::AlterColumnTypeBlockedByFk { table, column } => {
Some(DiffError::TypeChangeOnForeignKeyColumn {
table: table.clone(),
column: column.clone(),
})
}
_ => None,
})
}
fn find_unique_index_requires_dedup(plan: &MigrationPlan) -> Option<DiffError> {
let added: BTreeSet<(&str, &str)> = plan
.changes
.iter()
.filter_map(|c| match c {
SchemaChange::AddColumn { table, column } => {
Some((table.as_str(), column.name.as_str()))
}
_ => None,
})
.collect();
plan.changes.iter().find_map(|c| match c {
SchemaChange::AddIndex { table, index } if index.unique => {
let touches_new_column = index
.columns
.iter()
.any(|col| added.contains(&(table.as_str(), col.as_str())));
(!touches_new_column).then(|| DiffError::UniqueIndexRequiresDedup {
table: table.clone(),
index: index.name.clone(),
})
}
_ => None,
})
}
const PG_MAX_IDENTIFIER_BYTES: usize = 63;
fn generated_identifiers(plan: &MigrationPlan) -> Vec<String> {
let mut names = Vec::new();
for change in &plan.changes {
match change {
SchemaChange::AddForeignKey { table, column, .. } => {
names.push(bounded_pg_identifier(&format!("{table}_{column}_fkey")));
}
SchemaChange::AddIndex { index, .. } | SchemaChange::RenameIndex { index, .. } => {
names.push(index.name.clone());
}
SchemaChange::CreateTable(table) => {
names.extend(table.indexes.iter().map(|i| i.name.clone()));
}
_ => {}
}
}
names
}
fn truncate_to_bytes(name: &str, max: usize) -> &str {
if name.len() <= max {
return name;
}
let mut end = max;
while !name.is_char_boundary(end) {
end -= 1;
}
&name[..end]
}
fn truncate_pg_identifier(name: &str) -> &str {
truncate_to_bytes(name, PG_MAX_IDENTIFIER_BYTES)
}
fn short_identifier_hash(raw: &str) -> String {
let digest = fnv1a_64(raw.as_bytes());
format!("{digest:016x}")[..8].to_owned()
}
const fn fnv1a_64(bytes: &[u8]) -> u64 {
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
let prime: u64 = 0x0000_0100_0000_01b3;
let mut i = 0;
while i < bytes.len() {
hash ^= bytes[i] as u64;
hash = hash.wrapping_mul(prime);
i += 1;
}
hash
}
fn bounded_pg_identifier(raw: &str) -> String {
if raw.len() <= PG_MAX_IDENTIFIER_BYTES {
return raw.to_owned();
}
let hash = short_identifier_hash(raw);
let head_budget = PG_MAX_IDENTIFIER_BYTES - (hash.len() + 1);
let head = truncate_to_bytes(raw, head_budget);
format!("{head}_{hash}")
}
fn find_identifier_limit_violation(plan: &MigrationPlan) -> Option<DiffError> {
if plan.backend != Backend::Postgres {
return None;
}
if let Some(name) = plan.changes.iter().find_map(|c| match c {
SchemaChange::RenameTable { to, .. } | SchemaChange::RenameColumn { to, .. }
if to.len() > PG_MAX_IDENTIFIER_BYTES =>
{
Some(to.clone())
}
_ => None,
}) {
return Some(DiffError::GeneratedIdentifierTooLong { name });
}
let mut by_truncated: BTreeMap<String, String> = BTreeMap::new();
for name in generated_identifiers(plan) {
if name.len() > PG_MAX_IDENTIFIER_BYTES {
return Some(DiffError::GeneratedIdentifierTooLong { name });
}
if by_truncated
.insert(truncate_pg_identifier(&name).to_owned(), name.clone())
.is_some()
{
return Some(DiffError::GeneratedIdentifierTooLong { name });
}
}
None
}
fn find_possible_rename(plan: &MigrationPlan) -> Option<DiffError> {
let mut per_table: BTreeMap<String, (Vec<String>, Vec<String>)> = BTreeMap::new();
for change in &plan.changes {
match change {
SchemaChange::DropColumn { table, column } => {
per_table
.entry(table.clone())
.or_default()
.0
.push(column.name.clone());
}
SchemaChange::AddColumn { table, column } => {
per_table
.entry(table.clone())
.or_default()
.1
.push(column.name.clone());
}
_ => {}
}
}
per_table
.into_iter()
.find(|(_, (dropped, added))| !dropped.is_empty() && !added.is_empty())
.map(|(table, (dropped, added))| DiffError::PossibleRename {
table,
dropped,
added,
})
}
fn find_non_implicit_conversion(plan: &MigrationPlan) -> Option<DiffError> {
if plan.backend != Backend::Postgres {
return None;
}
plan.changes.iter().find_map(|c| match c {
SchemaChange::AlterColumnType {
table,
column,
from,
to,
} if !is_implicit_pg_type_cast(from, to) => Some(DiffError::NonImplicitTypeConversion {
table: table.clone(),
column: column.clone(),
from: from.sql_type(plan.backend),
to: to.sql_type(plan.backend),
}),
_ => None,
})
}
const fn is_implicit_pg_type_cast(from: &ColumnType, to: &ColumnType) -> bool {
matches!(
(from, to),
(ColumnType::Int32, ColumnType::Int64) | (ColumnType::Float32, ColumnType::Float64)
)
}
#[derive(Debug, Clone, Default)]
pub struct SchemaContext {
pub desired: BTreeMap<String, Table>,
pub baseline: BTreeMap<String, Table>,
pub skipped_decimals: BTreeSet<(String, String)>,
}
impl SchemaContext {
#[must_use]
pub fn from_tables(desired: &[Table], baseline: &[Table]) -> Self {
Self {
desired: desired
.iter()
.map(|t| (t.name.clone(), t.clone()))
.collect(),
baseline: baseline
.iter()
.map(|t| (t.name.clone(), t.clone()))
.collect(),
skipped_decimals: BTreeSet::new(),
}
}
#[must_use]
pub fn with_skipped_decimals(mut self, desired: &ParsedSchema) -> Self {
self.skipped_decimals = desired
.diagnostics
.iter()
.filter(|d| {
matches!(
ColumnType::from_rust_type(&d.rust_type),
Some(ColumnType::Decimal { .. })
)
})
.map(|d| (d.table.clone(), d.field.clone()))
.collect();
self
}
}
#[allow(dead_code)]
pub fn emit_up_sql(plan: &MigrationPlan) -> Result<String, EmitError> {
emit_up_sql_with_context(plan, &SchemaContext::default())
}
#[allow(dead_code)]
pub fn emit_down_sql(plan: &MigrationPlan) -> Result<String, EmitError> {
emit_down_sql_with_context(plan, &SchemaContext::default())
}
pub fn emit_up_sql_with_context(
plan: &MigrationPlan,
ctx: &SchemaContext,
) -> Result<String, EmitError> {
match plan.backend {
Backend::Postgres => emit_up_sql_pg(plan),
Backend::Sqlite => emit_up_sql_sqlite(plan, ctx),
}
}
pub fn emit_down_sql_with_context(
plan: &MigrationPlan,
ctx: &SchemaContext,
) -> Result<String, EmitError> {
match plan.backend {
Backend::Postgres => emit_down_sql_pg(plan, ctx),
Backend::Sqlite => emit_down_sql_sqlite(plan, ctx),
}
}
fn emit_up_sql_pg(plan: &MigrationPlan) -> Result<String, EmitError> {
let ordered = up_ordered(&plan.changes)?;
let mut groups = Vec::new();
for change in ordered {
let sql = emit_change_up(change, plan.backend)?;
let sql = sql.trim_end();
if !sql.is_empty() {
groups.push(sql.to_owned());
}
}
Ok(join_groups(&groups))
}
fn emit_down_sql_pg(plan: &MigrationPlan, ctx: &SchemaContext) -> Result<String, EmitError> {
let mut ordered = up_ordered(&plan.changes)?;
ordered.reverse();
let mut groups = Vec::new();
let mut dropped_by_table: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
let mut renames = Vec::new();
for change in ordered {
let sql = emit_change_down(change, plan.backend)?;
let sql = sql.trim_end();
if !sql.is_empty() {
if is_rename(change) {
renames.push(sql.to_owned());
} else {
groups.push(sql.to_owned());
}
}
if let SchemaChange::DropColumn { table, column } = change {
dropped_by_table
.entry(table.clone())
.or_default()
.insert(column.name.clone());
}
}
for (table, dropped) in &dropped_by_table {
for idx in retained_indexes_depending_on_any(ctx, table, dropped) {
groups.push(index_sql(table, idx).trim_end().to_owned());
}
}
groups.extend(renames);
Ok(join_groups(&groups))
}
fn retained_indexes_depending_on_any<'a>(
ctx: &'a SchemaContext,
table: &str,
dropped_columns: &BTreeSet<String>,
) -> Vec<&'a Index> {
ctx.baseline.get(table).map_or_else(Vec::new, |t| {
t.indexes
.iter()
.filter(|i| {
i.definition.is_some()
&& dropped_columns
.iter()
.any(|c| index_depends_on_column(i, c, Backend::Postgres))
})
.collect()
})
}
fn sqlite_rebuild_tables(changes: &[SchemaChange]) -> BTreeSet<String> {
changes
.iter()
.filter_map(|c| match c {
SchemaChange::AlterColumnType { table, .. }
| SchemaChange::DropNotNull { table, .. }
| SchemaChange::SetDefault { table, .. }
| SchemaChange::AddCheck { table, .. }
| SchemaChange::AddForeignKey { table, .. } => Some(table.clone()),
_ => None,
})
.collect()
}
fn fk_type_change_columns(changes: &[&SchemaChange], table: &str) -> Vec<String> {
changes
.iter()
.filter_map(|c| match c {
SchemaChange::AlterColumnTypeBlockedByFk { table: t, column } if t == table => {
Some(column.clone())
}
_ => None,
})
.collect::<BTreeSet<String>>()
.into_iter()
.collect()
}
const fn change_table_name(change: &SchemaChange) -> &str {
match change {
SchemaChange::CreateTable(t) | SchemaChange::DropTable(t) => t.name.as_str(),
SchemaChange::RenameTable { to, .. } => to.as_str(),
SchemaChange::RenameColumn { table, .. }
| SchemaChange::RenameIndex { table, .. }
| SchemaChange::RenameConflict { table, .. }
| SchemaChange::AddColumn { table, .. }
| SchemaChange::DropColumn { table, .. }
| SchemaChange::AlterColumnType { table, .. }
| SchemaChange::SetNotNull { table, .. }
| SchemaChange::DropNotNull { table, .. }
| SchemaChange::SetDefault { table, .. }
| SchemaChange::AddForeignKey { table, .. }
| SchemaChange::AddIndex { table, .. }
| SchemaChange::DropIndex { table, .. }
| SchemaChange::AddCheck { table, .. }
| SchemaChange::PrimaryKeyChange { table }
| SchemaChange::ForeignKeyChange { table, .. }
| SchemaChange::IdentityChange { table, .. }
| SchemaChange::DropTableBlockedByInboundFk { table, .. }
| SchemaChange::AlterColumnTypeBlockedByFk { table, .. }
| SchemaChange::AddForeignKeyToExistingColumn { table, .. }
| SchemaChange::CreateTableBlockedBySkippedField { table, .. } => table.as_str(),
}
}
fn emit_up_sql_sqlite(plan: &MigrationPlan, ctx: &SchemaContext) -> Result<String, EmitError> {
let ordered = up_ordered(&plan.changes)?;
let rebuild = sqlite_rebuild_tables(&plan.changes);
let mut emitted: BTreeSet<String> = BTreeSet::new();
let mut groups = Vec::new();
for change in &ordered {
let table = change_table_name(change);
if rebuild.contains(table) && !is_rename(change) {
if emitted.insert(table.to_owned()) {
let block = emit_sqlite_rebuild(table, ctx, &ordered, RebuildLeg::Up)?;
let block = block.trim_end();
if !block.is_empty() {
groups.push(block.to_owned());
}
}
continue;
}
let sql = emit_change_up(change, plan.backend)?;
let sql = sql.trim_end();
if !sql.is_empty() {
groups.push(sql.to_owned());
}
}
Ok(join_groups(&groups))
}
fn emit_down_sql_sqlite(plan: &MigrationPlan, ctx: &SchemaContext) -> Result<String, EmitError> {
let mut ordered = up_ordered(&plan.changes)?;
ordered.reverse();
let rebuild = sqlite_rebuild_tables(&plan.changes);
let mut emitted: BTreeSet<String> = BTreeSet::new();
let mut groups = Vec::new();
for change in &ordered {
let table = change_table_name(change);
if rebuild.contains(table) && !is_rename(change) {
if emitted.insert(table.to_owned()) {
let block = emit_sqlite_rebuild(table, ctx, &ordered, RebuildLeg::Down)?;
let block = block.trim_end();
if !block.is_empty() {
groups.push(block.to_owned());
}
}
continue;
}
let sql = emit_change_down(change, plan.backend)?;
let sql = sql.trim_end();
if !sql.is_empty() {
groups.push(sql.to_owned());
}
}
Ok(join_groups(&groups))
}
#[derive(Debug, Clone, Copy)]
enum RebuildLeg {
Up,
Down,
}
fn emit_sqlite_rebuild(
table: &str,
ctx: &SchemaContext,
changes: &[&SchemaChange],
leg: RebuildLeg,
) -> Result<String, EmitError> {
let staging = format!("{table}__autumn_new");
if ctx.desired.contains_key(&staging) || ctx.baseline.contains_key(&staging) {
return Err(EmitError::SqliteRebuildUnsupported {
table: table.to_owned(),
kind: "table rebuild",
reason: format!(
"staging table name `{staging}` collides with an existing table; \
rename the model to free that name"
),
});
}
if let Some((child, action)) = rebuild_fires_child_action(table, ctx) {
return Err(EmitError::SqliteRebuildUnsupported {
table: table.to_owned(),
kind: "table rebuild",
reason: format!(
"`{child}` references it with ON DELETE {}. The migration runs in a \
transaction, where `PRAGMA foreign_keys=OFF` has no effect, so the \
rebuild's DROP TABLE would change the rows of `{child}`. Write this \
change as a manual migration",
action.sql()
),
});
}
let desired = ctx
.desired
.get(table)
.ok_or_else(|| missing_rebuild_shape(table, "desired"))?;
let baseline = ctx
.baseline
.get(table)
.ok_or_else(|| missing_rebuild_shape(table, "baseline"))?;
let post_migration = baseline_with_changes_applied(baseline, changes, table);
if let Some(column) = post_migration.columns.iter().find(|c| {
ctx.skipped_decimals
.contains(&(table.to_owned(), c.name.clone()))
|| (matches!(c.ty, ColumnType::Decimal { .. })
&& !desired.columns.iter().any(|d| d.name == c.name))
}) {
return Err(EmitError::SqliteRebuildUnsupported {
table: table.to_owned(),
kind: "table rebuild",
reason: format!(
"the model does not give the precision and scale of the decimal column \
`{}`, so the rebuild cannot write its `CHECK`. Correct the \
`#[decimal_shape]` of the field, or write this change as a manual migration",
column.name
),
});
}
let fk_cols = fk_type_change_columns(changes, table);
match leg {
RebuildLeg::Up => {
let mut new_shape = post_migration;
let mut added = with_desired_decimal_shapes(&mut new_shape, desired);
added.extend(decimal_type_changes(changes, table, leg));
Ok(render_sqlite_rebuild(
table, &new_shape, baseline, leg, &fk_cols, &added,
))
}
RebuildLeg::Down => {
let mut old_shape = baseline.clone();
let mut added = with_desired_decimal_shapes(&mut old_shape, desired);
added.extend(decimal_type_changes(changes, table, leg));
Ok(render_sqlite_rebuild(
table,
&old_shape,
&post_migration,
leg,
&fk_cols,
&added,
))
}
}
}
fn describe_foreign_key(fk: &ForeignKey) -> String {
format!("{}({}){}", fk.table, fk.column, fk.action_clauses())
}
fn rebuild_fires_child_action(
table: &str,
ctx: &SchemaContext,
) -> Option<(String, ForeignKeyAction)> {
ctx.baseline
.values()
.chain(ctx.desired.values())
.flat_map(|t| t.columns.iter().map(move |c| (t, c)))
.find_map(|(t, c)| {
let fk = c.references.as_ref().filter(|fk| fk.table == table)?;
match fk.on_delete? {
ForeignKeyAction::Restrict => None,
action => Some((format!("{}.{}", t.name, c.name), action)),
}
})
}
fn baseline_with_changes_applied(
baseline: &Table,
changes: &[&SchemaChange],
table: &str,
) -> Table {
let mut shape = baseline.clone();
for change in changes {
if change_table_name(change) != table {
continue;
}
match change {
SchemaChange::AddColumn { column, .. } => shape.columns.push(column.clone()),
SchemaChange::DropColumn { column, .. } => {
shape.columns.retain(|c| c.name != column.name);
shape.primary_key.retain(|n| n != &column.name);
shape
.indexes
.retain(|i| !index_depends_on_column(i, &column.name, Backend::Sqlite));
}
SchemaChange::AlterColumnType { column, to, .. } => {
if let Some(c) = shape.columns.iter_mut().find(|c| &c.name == column) {
c.ty = to.clone();
}
}
SchemaChange::DropNotNull { column, .. } => {
if let Some(c) = shape.columns.iter_mut().find(|c| &c.name == column) {
c.nullable = true;
}
}
SchemaChange::SetNotNull { column, .. } => {
if let Some(c) = shape.columns.iter_mut().find(|c| &c.name == column) {
c.nullable = false;
}
}
SchemaChange::SetDefault { column, to, .. } => {
if let Some(c) = shape.columns.iter_mut().find(|c| &c.name == column) {
c.default = Some(to.clone());
}
}
SchemaChange::AddCheck { check, .. } => shape.checks.push(check.clone()),
SchemaChange::AddForeignKey {
column,
foreign_key,
..
} => {
if let Some(c) = shape.columns.iter_mut().find(|c| &c.name == column) {
c.references = Some(foreign_key.clone());
}
}
SchemaChange::AddIndex { index, .. } => shape.indexes.push(index.clone()),
SchemaChange::DropIndex { index, .. } => {
shape.indexes.retain(|i| i.name != index.name);
}
_ => {}
}
}
shape
}
fn with_desired_decimal_shapes(shape: &mut Table, desired: &Table) -> Vec<String> {
let mut changed = Vec::new();
for column in &mut shape.columns {
if let Some(want) = desired.columns.iter().find(|c| c.name == column.name)
&& matches!(want.ty, ColumnType::Decimal { .. })
&& !matches!(column.ty, ColumnType::Decimal { .. })
&& column_types_equivalent(&column.ty, &want.ty, Backend::Sqlite)
{
column.ty = want.ty.clone();
changed.push(column.name.clone());
}
}
changed
}
fn decimal_type_changes(changes: &[&SchemaChange], table: &str, leg: RebuildLeg) -> Vec<String> {
changes
.iter()
.filter_map(|change| match change {
SchemaChange::AlterColumnType {
table: t,
column,
from,
to,
} if t == table
&& matches!(
if matches!(leg, RebuildLeg::Up) {
to
} else {
from
},
ColumnType::Decimal { .. }
) =>
{
Some(column.clone())
}
_ => None,
})
.collect()
}
fn missing_rebuild_shape(table: &str, which: &'static str) -> EmitError {
EmitError::SqliteRebuildUnsupported {
table: table.to_owned(),
kind: "table rebuild",
reason: format!(
"the {which} table shape is unavailable — call \
emit_up_sql_with_context / emit_down_sql_with_context with a \
SchemaContext populated from the desired and baseline tables"
),
}
}
fn render_sqlite_rebuild(
table: &str,
new_shape: &Table,
source_shape: &Table,
leg: RebuildLeg,
fk_type_change_cols: &[String],
new_decimal_checks: &[String],
) -> String {
let new_table = format!("{table}__autumn_new");
let source_cols: BTreeSet<&str> = source_shape
.columns
.iter()
.map(|c| c.name.as_str())
.collect();
let common: Vec<&str> = new_shape
.columns
.iter()
.map(|c| c.name.as_str())
.filter(|n| source_cols.contains(n))
.collect();
let col_list = common.join(", ");
let mut out = String::new();
if matches!(leg, RebuildLeg::Down) {
let _ = writeln!(
out,
"-- irreversible: a SQLite table rebuild rolled back to the prior shape may not restore data lost by a narrowing type change or dropped column"
);
}
let _ = writeln!(
out,
"-- autumn: SQLite table rebuild for `{table}` — ALTER-family changes require a full table recreate."
);
let _ = writeln!(
out,
"-- Transaction semantics: this migration must run inside a single transaction (diesel wraps each"
);
let _ = writeln!(
out,
"-- migration in one). NOTE: `PRAGMA foreign_keys` is a no-op inside a transaction, so the harness"
);
let _ = writeln!(
out,
"-- must ensure foreign-key enforcement is disabled around the migration."
);
let _ = writeln!(
out,
"-- autumn-safety: this recreate copies columns and re-creates indexes only. DROP TABLE drops the table's TRIGGERS (not restored here); VIEWS that reference the table are not dropped and may be left dangling or block the rename — re-create triggers and repair dependent views in a manual migration."
);
let _ = writeln!(
out,
"-- autumn-safety: `PRAGMA foreign_key_check` below only REPORTS violation rows (it does not raise); the recreate copies existing values verbatim and introduces no new orphans, but the migration runner must inspect the pragma's output to treat any pre-existing orphan as an error."
);
push_rebuild_advisories(&mut out, fk_type_change_cols, new_decimal_checks);
let preserve_seq = matches!(single_pk_column(source_shape), Some((_, IdKind::BigSerial)))
&& matches!(single_pk_column(new_shape), Some((_, IdKind::BigSerial)));
let _ = writeln!(out, "PRAGMA foreign_keys=OFF;");
if preserve_seq {
let _ = writeln!(
out,
"-- preserve the AUTOINCREMENT high-water mark so IDs issued-then-deleted are never reused"
);
let _ = writeln!(
out,
"CREATE TEMP TABLE _autumn_seq_{table} AS SELECT seq FROM sqlite_sequence WHERE name = '{table}';"
);
}
out.push_str(&render_create_table_body(
&new_table,
new_shape,
Backend::Sqlite,
));
let _ = writeln!(out, "INSERT INTO {new_table} ({col_list})");
let _ = writeln!(out, " SELECT {col_list} FROM {table};");
let _ = writeln!(out, "DROP TABLE {table};");
let _ = writeln!(out, "PRAGMA legacy_alter_table=ON;");
let _ = writeln!(out, "ALTER TABLE {new_table} RENAME TO {table};");
let _ = writeln!(out, "PRAGMA legacy_alter_table=OFF;");
let mut indexes: Vec<&Index> = new_shape.indexes.iter().collect();
indexes.sort_by(|a, b| a.name.cmp(&b.name));
for index in indexes {
let _ = writeln!(out, "{}", index_sql(table, index));
}
if preserve_seq {
let _ = writeln!(
out,
"UPDATE sqlite_sequence SET seq = (SELECT seq FROM _autumn_seq_{table})\n WHERE name = '{table}' AND (SELECT seq FROM _autumn_seq_{table}) IS NOT NULL;"
);
let _ = writeln!(
out,
"INSERT INTO sqlite_sequence (name, seq)\n SELECT '{table}', (SELECT seq FROM _autumn_seq_{table})\n WHERE (SELECT seq FROM _autumn_seq_{table}) IS NOT NULL\n AND NOT EXISTS (SELECT 1 FROM sqlite_sequence WHERE name = '{table}');"
);
let _ = writeln!(out, "DROP TABLE _autumn_seq_{table};");
}
let _ = writeln!(out, "PRAGMA foreign_key_check;");
let _ = writeln!(out, "PRAGMA foreign_keys=ON;");
out
}
fn push_rebuild_advisories(
out: &mut String,
fk_type_change_cols: &[String],
new_decimal_checks: &[String],
) {
if !fk_type_change_cols.is_empty() {
let cols = fk_type_change_cols.join(", ");
let _ = writeln!(
out,
"-- autumn-safety: foreign-key column(s) {cols} have their type changed by this recreate; SQLite does not enforce that they still match the referenced key's type, so ensure the referenced column stays type-compatible (Postgres rejects this change outright, which is why it is emitted only for SQLite)."
);
}
if !new_decimal_checks.is_empty() {
let cols = new_decimal_checks.join(", ");
let _ = writeln!(
out,
"-- autumn-safety: this recreate writes a new decimal CHECK for column(s) {cols}. If a row does not agree with the CHECK, the copy fails. Correct the rows first."
);
}
}
fn join_groups(groups: &[String]) -> String {
if groups.is_empty() {
return String::new();
}
let mut out = groups.join("\n\n");
out.push('\n');
out
}
fn up_ordered(changes: &[SchemaChange]) -> Result<Vec<&SchemaChange>, EmitError> {
let replaced = replaced_index_names(changes);
let renames: Vec<&SchemaChange> = changes.iter().filter(|c| is_rename(c)).collect();
let mut creates: Vec<&SchemaChange> = changes
.iter()
.filter(|c| matches!(c, SchemaChange::CreateTable(_)))
.collect();
topo_sort_creates(&mut creates)?;
let mut drops: Vec<&SchemaChange> = changes
.iter()
.filter(|c| matches!(c, SchemaChange::DropTable(_)))
.collect();
topo_sort_drops(&mut drops)?;
let mut rest: Vec<&SchemaChange> = changes
.iter()
.filter(|c| {
!is_rename(c) && !matches!(c, SchemaChange::CreateTable(_) | SchemaChange::DropTable(_))
})
.collect();
rest.sort_by_key(|c| up_sort_key(c, &replaced));
let mut ordered = renames;
ordered.extend(creates);
ordered.extend(rest);
ordered.extend(drops);
Ok(ordered)
}
const fn is_rename(change: &SchemaChange) -> bool {
matches!(
change,
SchemaChange::RenameTable { .. }
| SchemaChange::RenameColumn { .. }
| SchemaChange::RenameIndex { .. }
)
}
fn replaced_index_names(changes: &[SchemaChange]) -> BTreeSet<String> {
let added: BTreeSet<&str> = changes
.iter()
.filter_map(|c| match c {
SchemaChange::AddIndex { index, .. } => Some(index.name.as_str()),
_ => None,
})
.collect();
changes
.iter()
.filter_map(|c| match c {
SchemaChange::DropIndex { index, .. } if added.contains(index.name.as_str()) => {
Some(index.name.clone())
}
_ => None,
})
.collect()
}
fn up_sort_key(change: &SchemaChange, replaced: &BTreeSet<String>) -> (u8, u8) {
match change {
SchemaChange::DropIndex { index, .. } if replaced.contains(&index.name) => (3, 0),
SchemaChange::AddIndex { .. } => (3, 1),
other => (up_bucket(other), 1),
}
}
fn topo_sort_creates(creates: &mut [&SchemaChange]) -> Result<(), EmitError> {
if creates.len() < 2 {
return Ok(());
}
let tables: BTreeMap<&str, &Table> = creates
.iter()
.filter_map(|c| match c {
SchemaChange::CreateTable(t) => Some((t.name.as_str(), t)),
_ => None,
})
.collect();
let names: BTreeSet<&str> = tables.keys().copied().collect();
let mut deps: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
for (&name, table) in &tables {
let mut d = BTreeSet::new();
for col in &table.columns {
if let Some(fk) = &col.references {
let target = fk.table.as_str();
if target != name && names.contains(target) {
d.insert(target);
}
}
}
deps.insert(name, d);
}
let mut remaining: BTreeSet<&str> = names;
let mut order: BTreeMap<&str, usize> = BTreeMap::new();
while !remaining.is_empty() {
let ready = remaining
.iter()
.copied()
.find(|n| deps[n].iter().all(|d| !remaining.contains(d)));
if let Some(n) = ready {
order.insert(n, order.len());
remaining.remove(n);
} else {
let mut cyc: Vec<String> = remaining.iter().map(|s| (*s).to_owned()).collect();
cyc.sort();
return Err(EmitError::CyclicTableDependencies { tables: cyc });
}
}
creates.sort_by_key(|c| match c {
SchemaChange::CreateTable(t) => order.get(t.name.as_str()).copied().unwrap_or(usize::MAX),
_ => usize::MAX,
});
Ok(())
}
fn topo_sort_drops(drops: &mut [&SchemaChange]) -> Result<(), EmitError> {
if drops.len() < 2 {
return Ok(());
}
let tables: BTreeMap<&str, &Table> = drops
.iter()
.filter_map(|c| match c {
SchemaChange::DropTable(t) => Some((t.name.as_str(), t)),
_ => None,
})
.collect();
let names: BTreeSet<&str> = tables.keys().copied().collect();
let mut deps: BTreeMap<&str, BTreeSet<&str>> =
names.iter().map(|&n| (n, BTreeSet::new())).collect();
for (&name, table) in &tables {
for col in &table.columns {
if let Some(fk) = &col.references {
let target = fk.table.as_str();
if target != name && names.contains(target) {
deps.get_mut(target)
.expect("target is an in-batch name")
.insert(name);
}
}
}
}
let mut remaining: BTreeSet<&str> = names;
let mut order: BTreeMap<&str, usize> = BTreeMap::new();
while !remaining.is_empty() {
let ready = remaining
.iter()
.copied()
.find(|n| deps[n].iter().all(|d| !remaining.contains(d)));
if let Some(n) = ready {
order.insert(n, order.len());
remaining.remove(n);
} else {
let mut cyc: Vec<String> = remaining.iter().map(|s| (*s).to_owned()).collect();
cyc.sort();
return Err(EmitError::CyclicTableDependencies { tables: cyc });
}
}
drops.sort_by_key(|c| match c {
SchemaChange::DropTable(t) => order.get(t.name.as_str()).copied().unwrap_or(usize::MAX),
_ => usize::MAX,
});
Ok(())
}
const fn up_bucket(change: &SchemaChange) -> u8 {
match change {
SchemaChange::RenameTable { .. }
| SchemaChange::RenameColumn { .. }
| SchemaChange::RenameIndex { .. }
| SchemaChange::CreateTable(_) => 0,
SchemaChange::AddColumn { .. } => 1,
SchemaChange::AlterColumnType { .. }
| SchemaChange::SetNotNull { .. }
| SchemaChange::DropNotNull { .. }
| SchemaChange::SetDefault { .. } => 2,
SchemaChange::AddIndex { .. } => 3,
SchemaChange::AddCheck { .. } | SchemaChange::AddForeignKey { .. } => 4,
SchemaChange::DropIndex { .. } => 5,
SchemaChange::DropColumn { .. } => 6,
SchemaChange::DropTable(_) => 7,
SchemaChange::RenameConflict { .. }
| SchemaChange::PrimaryKeyChange { .. }
| SchemaChange::ForeignKeyChange { .. }
| SchemaChange::IdentityChange { .. }
| SchemaChange::DropTableBlockedByInboundFk { .. }
| SchemaChange::AlterColumnTypeBlockedByFk { .. }
| SchemaChange::AddForeignKeyToExistingColumn { .. }
| SchemaChange::CreateTableBlockedBySkippedField { .. } => 9,
}
}
fn emit_change_up(change: &SchemaChange, backend: Backend) -> Result<String, EmitError> {
match change {
SchemaChange::RenameTable { from, to } => {
Ok(format!("ALTER TABLE {from} RENAME TO {to};\n"))
}
SchemaChange::RenameColumn { table, from, to } => Ok(format!(
"ALTER TABLE {table} RENAME COLUMN {from} TO {to};\n"
)),
SchemaChange::RenameIndex { table, from, index } => {
Ok(emit_rename_index(table, from, index, backend))
}
SchemaChange::CreateTable(table) => Ok(emit_create_table(table, backend)),
SchemaChange::DropTable(table) => Ok(format!(
"-- autumn-safety: this DROP TABLE is not checked against generated \
#[belongs_to(...)] association foreign keys, which the offline snapshot cannot see; \
if a retained table has an invisible association FK to `{0}`, this will fail to apply \
-- verify with schema introspection or drop the FK manually first\n\
DROP TABLE {0};\n",
table.name
)),
SchemaChange::AddColumn { table, column } => emit_add_column(table, column, backend),
SchemaChange::DropColumn { table, column } => Ok(format!(
"ALTER TABLE {table} DROP COLUMN {};\n",
column.name
)),
SchemaChange::AlterColumnType {
table, column, to, ..
} => {
require_pg(backend, "AlterColumnType")?;
Ok(format!(
"ALTER TABLE {table} ALTER COLUMN {column} TYPE {};\n",
to.sql_type(backend)
))
}
SchemaChange::SetNotNull { table, column } => {
require_pg(backend, "SetNotNull")?;
Ok(format!(
"-- autumn-safety: potentially-blocking -- existing NULLs must be backfilled first\n\
ALTER TABLE {table} ALTER COLUMN {column} SET NOT NULL;\n"
))
}
SchemaChange::DropNotNull { table, column } => {
require_pg(backend, "DropNotNull")?;
Ok(format!(
"ALTER TABLE {table} ALTER COLUMN {column} DROP NOT NULL;\n"
))
}
SchemaChange::SetDefault {
table, column, to, ..
} => {
require_pg(backend, "SetDefault")?;
Ok(format!(
"ALTER TABLE {table} ALTER COLUMN {column} SET DEFAULT {};\n",
default_sql(to, backend)
))
}
SchemaChange::AddIndex { table, index } => Ok(format!("{}\n", index_sql(table, index))),
SchemaChange::DropIndex { index, .. } => Ok(format!("DROP INDEX {};\n", index.name)),
SchemaChange::AddCheck { table, check } => {
require_pg(backend, "AddCheck")?;
Ok(check.name.as_ref().map_or_else(
|| format!("ALTER TABLE {table} ADD CHECK ({});\n", check.expression),
|name| {
format!(
"ALTER TABLE {table} ADD CONSTRAINT {name} CHECK ({});\n",
check.expression
)
},
))
}
SchemaChange::AddForeignKey {
table,
column,
foreign_key,
} => {
require_pg(backend, "AddForeignKey")?;
let constraint = bounded_pg_identifier(&format!("{table}_{column}_fkey"));
Ok(format!(
"ALTER TABLE {table} ADD CONSTRAINT {constraint} \
FOREIGN KEY ({column}) REFERENCES {}({}){};\n",
foreign_key.table,
foreign_key.column,
foreign_key.action_clauses()
))
}
SchemaChange::RenameConflict { .. }
| SchemaChange::PrimaryKeyChange { .. }
| SchemaChange::ForeignKeyChange { .. }
| SchemaChange::IdentityChange { .. }
| SchemaChange::DropTableBlockedByInboundFk { .. }
| SchemaChange::AlterColumnTypeBlockedByFk { .. }
| SchemaChange::AddForeignKeyToExistingColumn { .. }
| SchemaChange::CreateTableBlockedBySkippedField { .. } => Ok(String::new()),
}
}
fn emit_change_down(change: &SchemaChange, backend: Backend) -> Result<String, EmitError> {
match change {
SchemaChange::RenameTable { from, to } => {
Ok(format!("ALTER TABLE {to} RENAME TO {from};\n"))
}
SchemaChange::RenameColumn { table, from, to } => Ok(format!(
"ALTER TABLE {table} RENAME COLUMN {to} TO {from};\n"
)),
SchemaChange::RenameIndex { table, from, index } => {
let old = crate::schema::rename::renamed_index(index, from).unwrap_or_else(|| {
let mut old = index.clone();
old.name.clone_from(from);
old
});
Ok(emit_rename_index(table, &index.name, &old, backend))
}
SchemaChange::CreateTable(table) => Ok(format!("DROP TABLE {};\n", table.name)),
SchemaChange::DropTable(table) => {
let recreate = emit_create_table(table, backend);
Ok(format!(
"-- irreversible: table data dropped by this migration cannot be restored\n\
{recreate}"
))
}
SchemaChange::AddColumn { table, column } => Ok(format!(
"ALTER TABLE {table} DROP COLUMN {};\n",
column.name
)),
SchemaChange::DropColumn { table, column } => {
let readd = emit_add_column(table, column, backend)?;
Ok(format!(
"-- irreversible: column data dropped by this migration cannot be restored\n\
{readd}"
))
}
SchemaChange::AlterColumnType {
table,
column,
from,
..
} => {
require_pg(backend, "AlterColumnType")?;
Ok(format!(
"-- irreversible: a narrowing type change may have lost data; review before rolling back\n\
ALTER TABLE {table} ALTER COLUMN {column} TYPE {};\n",
from.sql_type(backend)
))
}
SchemaChange::SetNotNull { table, column } => {
require_pg(backend, "SetNotNull")?;
Ok(format!(
"ALTER TABLE {table} ALTER COLUMN {column} DROP NOT NULL;\n"
))
}
SchemaChange::DropNotNull { table, column } => {
require_pg(backend, "DropNotNull")?;
Ok(format!(
"-- potentially-blocking: rolling back re-adds NOT NULL; existing NULL rows will block it\n\
ALTER TABLE {table} ALTER COLUMN {column} SET NOT NULL;\n"
))
}
SchemaChange::SetDefault {
table,
column,
from,
..
} => {
require_pg(backend, "SetDefault")?;
Ok(from.as_ref().map_or_else(
|| format!("ALTER TABLE {table} ALTER COLUMN {column} DROP DEFAULT;\n"),
|d| {
format!(
"ALTER TABLE {table} ALTER COLUMN {column} SET DEFAULT {};\n",
default_sql(d, backend)
)
},
))
}
SchemaChange::AddIndex { index, .. } => Ok(format!("DROP INDEX {};\n", index.name)),
SchemaChange::DropIndex { table, index } => Ok(format!("{}\n", index_sql(table, index))),
SchemaChange::AddCheck { table, check } => Ok(check.name.as_ref().map_or_else(
|| "-- manual: unnamed CHECK cannot be auto-dropped\n".to_owned(),
|name| format!("ALTER TABLE {table} DROP CONSTRAINT {name};\n"),
)),
SchemaChange::AddForeignKey { table, column, .. } => {
let constraint = bounded_pg_identifier(&format!("{table}_{column}_fkey"));
Ok(format!(
"ALTER TABLE {table} DROP CONSTRAINT {constraint};\n"
))
}
SchemaChange::RenameConflict { .. }
| SchemaChange::PrimaryKeyChange { .. }
| SchemaChange::ForeignKeyChange { .. }
| SchemaChange::IdentityChange { .. }
| SchemaChange::DropTableBlockedByInboundFk { .. }
| SchemaChange::AlterColumnTypeBlockedByFk { .. }
| SchemaChange::AddForeignKeyToExistingColumn { .. }
| SchemaChange::CreateTableBlockedBySkippedField { .. } => Ok(String::new()),
}
}
const fn require_pg(backend: Backend, kind: &'static str) -> Result<(), EmitError> {
match backend {
Backend::Postgres => Ok(()),
Backend::Sqlite => Err(EmitError::UnsupportedOnBackend { kind, backend }),
}
}
fn emit_rename_index(table: &str, from: &str, index: &Index, backend: Backend) -> String {
match backend {
Backend::Postgres => format!("ALTER INDEX {from} RENAME TO {};\n", index.name),
Backend::Sqlite => format!("DROP INDEX {from};\n{}\n", index_sql(table, index)),
}
}
fn emit_create_table(table: &Table, backend: Backend) -> String {
let mut out = render_create_table_body(&table.name, table, backend);
let mut indexes: Vec<&Index> = table.indexes.iter().collect();
indexes.sort_by(|a, b| a.name.cmp(&b.name));
for index in indexes {
let _ = writeln!(out, "{}", index_sql(&table.name, index));
}
out
}
fn render_create_table_body(name: &str, table: &Table, backend: Backend) -> String {
let single_pk = single_pk_column(table);
let mut lines: Vec<String> = Vec::with_capacity(table.columns.len() + 2);
for col in &table.columns {
if let Some((pk_col, kind)) = &single_pk
&& pk_col.name == col.name
{
lines.push(format!(" {} {}", col.name, kind.pk_sql(backend)));
} else {
let render_unique = col.unique && !column_covered_by_unique_index(table, &col.name);
lines.push(format!(
" {}",
render_column_def(col, backend, render_unique)
));
}
}
if single_pk.is_none() && !table.primary_key.is_empty() {
lines.push(format!(
" PRIMARY KEY ({})",
table.primary_key.join(", ")
));
}
for check in &table.checks {
match &check.name {
Some(cname) => lines.push(format!(
" CONSTRAINT {cname} CHECK ({})",
check.expression
)),
None => lines.push(format!(" CHECK ({})", check.expression)),
}
}
format!("CREATE TABLE {name} (\n{}\n);\n", lines.join(",\n"))
}
fn emit_add_column(table: &str, column: &Column, backend: Backend) -> Result<String, EmitError> {
if backend == Backend::Sqlite && !column.nullable && column.default.is_none() {
return Err(EmitError::UnsupportedOnBackend {
kind: "AddColumn (NOT NULL without a default on SQLite)",
backend,
});
}
let mut out = String::new();
if !column.nullable && column.default.is_none() {
let _ = writeln!(
out,
"-- autumn-safety: potentially-blocking -- add a DEFAULT or backfill existing rows before enforcing NOT NULL"
);
}
let _ = writeln!(
out,
"ALTER TABLE {table} ADD COLUMN {};",
render_column_def(column, backend, false)
);
Ok(out)
}
fn render_column_def(column: &Column, backend: Backend, render_unique: bool) -> String {
let mut def = format!(
"{} {} {}",
column.name,
column.ty.sql_type(backend),
nullability(column.nullable)
);
if render_unique {
def.push_str(" UNIQUE");
}
if let Some(fk) = &column.references {
let _ = write!(
def,
" REFERENCES {}({}){}",
fk.table,
fk.column,
fk.action_clauses()
);
}
if let Some(default) = &column.default {
let _ = write!(def, " DEFAULT {}", default_sql(default, backend));
}
if backend == Backend::Sqlite
&& let ColumnType::Decimal { precision, scale } = column.ty
{
let check = crate::generate::schema_edit::sqlite_decimal_check(
&column.name,
u32::from(precision),
u32::from(scale),
);
let _ = write!(def, " {check}");
}
def
}
fn column_covered_by_unique_index(table: &Table, col: &str) -> bool {
table
.indexes
.iter()
.any(|idx| full_unique_key_columns(idx).is_some_and(|k| k.len() == 1 && k.contains(col)))
}
fn index_sql(table: &str, index: &Index) -> String {
if let Some(def) = &index.definition {
let def = def.trim_end().trim_end_matches(';');
return format!("{def};");
}
let unique = if index.unique { "UNIQUE " } else { "" };
format!(
"CREATE {unique}INDEX {} ON {table} ({});",
index.name,
index.columns.join(", ")
)
}
const fn nullability(nullable: bool) -> &'static str {
if nullable { "NULL" } else { "NOT NULL" }
}
fn default_sql(default: &ColumnDefault, backend: Backend) -> String {
match default {
ColumnDefault::Now => match backend {
Backend::Postgres => "NOW()".to_owned(),
Backend::Sqlite => "CURRENT_TIMESTAMP".to_owned(),
},
ColumnDefault::Sql(sql)
if backend == Backend::Sqlite
&& sql.trim() == autumn_schema_core::SQLITE_UUID_V4_SQL =>
{
format!("({})", sql.trim())
}
ColumnDefault::Sql(sql) => sql.clone(),
}
}
fn single_pk_column(table: &Table) -> Option<(&Column, IdKind)> {
if table.primary_key.len() != 1 {
return None;
}
let name = &table.primary_key[0];
let column = table.columns.iter().find(|c| &c.name == name)?;
pk_kind_for(column).map(|kind| (column, kind))
}
fn single_pk_serial(table: &Table) -> Option<SerialKind> {
if table.primary_key.len() != 1 {
return None;
}
let name = &table.primary_key[0];
table.columns.iter().find(|c| &c.name == name)?.serial
}
fn serial_kinds_conflict(base: &Table, want: &Table) -> bool {
matches!(
(single_pk_serial(base), single_pk_serial(want)),
(Some(a), Some(b)) if a != b
)
}
fn pk_kind_for(column: &Column) -> Option<IdKind> {
if column.serial == Some(SerialKind::Plain) {
return None;
}
match &column.ty {
ColumnType::Int64 if column.default.is_none() => Some(IdKind::BigSerial),
ColumnType::Int32 if is_nextval_default(column) => Some(IdKind::Serial),
ColumnType::Uuid if is_convention_uuid_default(column) => Some(IdKind::Uuid),
ColumnType::Text if is_sqlite_uuid_default(column) => Some(IdKind::Uuid),
_ => None,
}
}
fn is_convention_uuid_default(column: &Column) -> bool {
matches!(
&column.default,
Some(ColumnDefault::Sql(sql))
if sql.trim() == "gen_random_uuid()"
|| sql.trim() == autumn_schema_core::SQLITE_UUID_V4_SQL
)
}
fn is_sqlite_uuid_default(column: &Column) -> bool {
matches!(
&column.default,
Some(ColumnDefault::Sql(sql)) if sql.trim() == autumn_schema_core::SQLITE_UUID_V4_SQL
)
}
fn is_nextval_default(column: &Column) -> bool {
matches!(
&column.default,
Some(ColumnDefault::Sql(sql)) if sql.trim_start().to_ascii_lowercase().starts_with("nextval(")
)
}
#[must_use]
pub fn describe_plan(plan: &MigrationPlan) -> String {
let backend = match plan.backend {
Backend::Postgres => "postgres",
Backend::Sqlite => "sqlite",
};
let mut out = format!(
"Migration plan ({backend}): {} change(s)\n",
plan.changes.len()
);
for change in &plan.changes {
let _ = writeln!(out, " {}", describe_change(change));
}
out
}
fn describe_change(change: &SchemaChange) -> String {
match change {
SchemaChange::RenameTable { from, to } => format!("~ RENAME TABLE {from} TO {to}"),
SchemaChange::RenameColumn { table, from, to } => {
format!("~ RENAME COLUMN {table}.{from} TO {to}")
}
SchemaChange::RenameIndex { table, from, index } => {
format!("~ RENAME INDEX {from} TO {} ON {table}", index.name)
}
SchemaChange::RenameConflict { table, reason } => {
format!("! RENAME on {table}: {reason} (refused)")
}
SchemaChange::CreateTable(t) => format!("+ CREATE TABLE {}", t.name),
SchemaChange::DropTable(t) => format!("- DROP TABLE {}", t.name),
SchemaChange::AddColumn { table, column } => {
format!("+ ADD COLUMN {table}.{}", column.name)
}
SchemaChange::DropColumn { table, column } => {
format!("- DROP COLUMN {table}.{}", column.name)
}
SchemaChange::AlterColumnType {
table,
column,
from,
to,
} => format!(
"~ ALTER COLUMN {table}.{column} TYPE {} (was {})",
to.sql_type(Backend::Postgres),
from.sql_type(Backend::Postgres)
),
SchemaChange::SetNotNull { table, column } => {
format!("~ SET NOT NULL {table}.{column}")
}
SchemaChange::DropNotNull { table, column } => {
format!("~ DROP NOT NULL {table}.{column}")
}
SchemaChange::SetDefault { table, column, .. } => {
format!("~ SET DEFAULT {table}.{column}")
}
SchemaChange::AddForeignKey { table, column, .. } => {
format!("+ ADD FOREIGN KEY {table}.{column}")
}
SchemaChange::AddIndex { table, index } => {
format!("+ ADD INDEX {} ON {table}", index.name)
}
SchemaChange::DropIndex { table, index } => {
format!("- DROP INDEX {} ON {table}", index.name)
}
SchemaChange::AddCheck { table, check } => format!(
"+ ADD CHECK {} ON {table}",
check.name.as_deref().unwrap_or("(unnamed)")
),
SchemaChange::PrimaryKeyChange { table } => {
format!("! PRIMARY KEY CHANGE on {table} (refused)")
}
SchemaChange::ForeignKeyChange {
table,
column,
from,
to,
} => {
format!("! FOREIGN KEY CHANGE on {table}.{column}: {from} -> {to} (refused)")
}
SchemaChange::IdentityChange { table, column } => {
format!("! IDENTITY CHANGE on {table}.{column} (refused)")
}
SchemaChange::DropTableBlockedByInboundFk {
table,
referencing_table,
referencing_column,
} => format!(
"! DROP TABLE {table} blocked by retained FK {referencing_table}.{referencing_column} (refused)"
),
SchemaChange::AlterColumnTypeBlockedByFk { table, column } => {
format!(
"! ALTER COLUMN {table}.{column} TYPE blocked by foreign-key constraint (refused)"
)
}
SchemaChange::AddForeignKeyToExistingColumn { table, column } => {
format!("! ADD FOREIGN KEY on pre-existing column {table}.{column} (refused)")
}
SchemaChange::CreateTableBlockedBySkippedField { table, fields } => {
format!(
"! CREATE TABLE {table} blocked by parser-skipped field(s) [{}] (refused)",
fields.join(", ")
)
}
}
}
#[cfg(test)]
#[allow(clippy::needless_raw_string_hashes)]
mod tests {
use super::*;
use autumn_schema_core::{ForeignKey, ForeignKeyAction, Table};
use crate::schema::parse::SchemaDiagnostic;
fn col(name: &str, ty: ColumnType) -> Column {
Column::new(name, ty)
}
fn posts_with(columns: Vec<Column>) -> Table {
let mut t = Table::new("posts", Backend::Postgres);
let mut id = col("id", ColumnType::Int64);
id.primary_key = true;
t.primary_key.push("id".to_owned());
t.columns.push(id);
t.columns.extend(columns);
t
}
fn posts_ref_table(name: &str, extra: Column) -> Table {
let mut t = Table::new(name, Backend::Postgres);
let mut id = col("id", ColumnType::Int64);
id.primary_key = true;
t.primary_key.push("id".to_owned());
t.columns.push(id);
t.columns.push(extra);
t
}
fn parsed(tables: Vec<Table>, diagnostics: Vec<SchemaDiagnostic>) -> ParsedSchema {
ParsedSchema {
diagnostics,
..ParsedSchema::from_tables(tables)
}
}
fn diag(model: &str, table: &str, field: &str) -> SchemaDiagnostic {
SchemaDiagnostic {
model: model.to_owned(),
table: table.to_owned(),
field: field.to_owned(),
rust_type: "Unknown".to_owned(),
message: format!("skipped {model}.{field}"),
}
}
const DEFAULT_OPTS: DiffOptions = DiffOptions {
allow_destructive: false,
definitions_authoritative: false,
};
const ALLOW: DiffOptions = DiffOptions {
allow_destructive: true,
definitions_authoritative: false,
};
const AUTHORITATIVE: DiffOptions = DiffOptions {
allow_destructive: false,
definitions_authoritative: true,
};
fn posts_with_id_serial(kind: Option<SerialKind>) -> Table {
let mut t = posts_with(vec![]);
t.columns[0].serial = kind;
t
}
#[test]
fn serial_marker_legacy_none_is_compatible_with_parser_bigserial() {
let legacy = vec![posts_with_id_serial(None)];
let model = posts_with_id_serial(Some(SerialKind::BigSerial));
for opts in [DEFAULT_OPTS, AUTHORITATIVE] {
let plan = diff_schema(&legacy, &parsed(vec![model.clone()], vec![]), opts);
assert!(
!plan
.changes
.iter()
.any(|c| matches!(c, SchemaChange::PrimaryKeyChange { .. })),
"legacy None vs Some(BigSerial) must be compatible ({opts:?}): {:?}",
plan.changes
);
}
}
#[test]
fn serial_marker_matching_bigserial_is_clean() {
let base = vec![posts_with_id_serial(Some(SerialKind::BigSerial))];
let want = posts_with_id_serial(Some(SerialKind::BigSerial));
let plan = diff_schema(&base, &parsed(vec![want], vec![]), DEFAULT_OPTS);
assert!(
plan.changes.is_empty(),
"matching markers: {:?}",
plan.changes
);
}
#[test]
fn serial_marker_plain_conflicts_with_bigserial() {
let base = vec![posts_with_id_serial(Some(SerialKind::Plain))];
let want = posts_with_id_serial(Some(SerialKind::BigSerial));
let plan = diff_schema(&base, &parsed(vec![want], vec![]), DEFAULT_OPTS);
assert_eq!(
plan.changes,
vec![SchemaChange::PrimaryKeyChange {
table: "posts".to_owned(),
}]
);
}
#[test]
fn identity_change_flagged_only_in_authoritative_diff() {
let mut base_id = posts_with_id_serial(Some(SerialKind::BigSerial));
base_id.columns[0].identity = Some("ALWAYS".to_owned());
let base = vec![base_id];
let want = posts_with_id_serial(Some(SerialKind::BigSerial));
let auth = diff_schema(&base, &parsed(vec![want.clone()], vec![]), AUTHORITATIVE);
assert!(
auth.changes.iter().any(|c| matches!(
c,
SchemaChange::IdentityChange { table, column } if table == "posts" && column == "id"
)),
"authoritative diff flags the identity change: {:?}",
auth.changes
);
assert!(matches!(
guard_plan(&auth, AUTHORITATIVE).unwrap_err(),
DiffError::IdentityChange { .. }
));
let model = diff_schema(&base, &parsed(vec![want], vec![]), DEFAULT_OPTS);
assert!(
!model
.changes
.iter()
.any(|c| matches!(c, SchemaChange::IdentityChange { .. })),
"model diff never flags an identity change: {:?}",
model.changes
);
}
#[test]
fn no_op_identical_returns_empty_plan() {
let base = vec![posts_with(vec![col("body", ColumnType::Text)])];
let want = parsed(
vec![posts_with(vec![col("body", ColumnType::Text)])],
vec![],
);
let plan = diff_schema(&base, &want, DEFAULT_OPTS);
assert!(plan.is_empty(), "identical schemas → empty plan: {plan:?}");
}
#[test]
fn add_column_emits_add_column() {
let base = vec![posts_with(vec![])];
let mut bio = col("bio", ColumnType::Text);
bio.nullable = true;
let want = parsed(vec![posts_with(vec![bio.clone()])], vec![]);
let plan = diff_schema(&base, &want, DEFAULT_OPTS);
assert_eq!(plan.changes.len(), 1);
match &plan.changes[0] {
SchemaChange::AddColumn { table, column } => {
assert_eq!(table, "posts");
assert_eq!(column, &bio);
}
other => panic!("expected AddColumn, got {other:?}"),
}
}
#[test]
fn drop_column_present_in_plan_carries_baseline_column() {
let nickname = col("nickname", ColumnType::Text);
let base = vec![posts_with(vec![nickname.clone()])];
let want = parsed(vec![posts_with(vec![])], vec![]);
let plan = diff_schema(&base, &want, DEFAULT_OPTS);
assert_eq!(plan.changes.len(), 1);
match &plan.changes[0] {
SchemaChange::DropColumn { table, column } => {
assert_eq!(table, "posts");
assert_eq!(column, &nickname, "carries the baseline column for down");
}
other => panic!("expected DropColumn, got {other:?}"),
}
}
#[test]
fn alter_column_type_int32_to_int64() {
let base = vec![posts_with(vec![col("views", ColumnType::Int32)])];
let want = parsed(
vec![posts_with(vec![col("views", ColumnType::Int64)])],
vec![],
);
let plan = diff_schema(&base, &want, DEFAULT_OPTS);
assert_eq!(
plan.changes,
vec![SchemaChange::AlterColumnType {
table: "posts".to_owned(),
column: "views".to_owned(),
from: ColumnType::Int32,
to: ColumnType::Int64,
}]
);
}
#[test]
fn nullable_to_not_null_emits_set_not_null() {
let mut nullable_bio = col("bio", ColumnType::Text);
nullable_bio.nullable = true;
let base = vec![posts_with(vec![nullable_bio])];
let want = parsed(vec![posts_with(vec![col("bio", ColumnType::Text)])], vec![]);
let plan = diff_schema(&base, &want, DEFAULT_OPTS);
assert_eq!(
plan.changes,
vec![SchemaChange::SetNotNull {
table: "posts".to_owned(),
column: "bio".to_owned(),
}]
);
}
#[test]
fn not_null_to_nullable_emits_drop_not_null() {
let base = vec![posts_with(vec![col("bio", ColumnType::Text)])];
let mut nullable_bio = col("bio", ColumnType::Text);
nullable_bio.nullable = true;
let want = parsed(vec![posts_with(vec![nullable_bio])], vec![]);
let plan = diff_schema(&base, &want, DEFAULT_OPTS);
assert_eq!(
plan.changes,
vec![SchemaChange::DropNotNull {
table: "posts".to_owned(),
column: "bio".to_owned(),
}]
);
}
#[test]
fn set_default_added() {
let base = vec![posts_with(vec![col("created_at", ColumnType::Timestamp)])];
let mut created = col("created_at", ColumnType::Timestamp);
created.default = Some(ColumnDefault::Now);
let want = parsed(vec![posts_with(vec![created])], vec![]);
let plan = diff_schema(&base, &want, DEFAULT_OPTS);
assert_eq!(
plan.changes,
vec![SchemaChange::SetDefault {
table: "posts".to_owned(),
column: "created_at".to_owned(),
to: ColumnDefault::Now,
from: None,
}]
);
}
#[test]
fn add_index_and_drop_index_by_name() {
let idx = Index {
name: "idx_posts_body".to_owned(),
columns: vec!["body".to_owned()],
unique: false,
definition: None,
is_partial: false,
key_columns: Vec::new(),
};
let base = vec![posts_with(vec![col("body", ColumnType::Text)])];
let mut want_table = posts_with(vec![col("body", ColumnType::Text)]);
want_table.indexes.push(idx.clone());
let plan = diff_schema(&base, &parsed(vec![want_table], vec![]), DEFAULT_OPTS);
assert_eq!(
plan.changes,
vec![SchemaChange::AddIndex {
table: "posts".to_owned(),
index: idx.clone(),
}]
);
let mut base_table = posts_with(vec![col("body", ColumnType::Text)]);
base_table.indexes.push(idx.clone());
let want = parsed(
vec![posts_with(vec![col("body", ColumnType::Text)])],
vec![],
);
let plan = diff_schema(&[base_table], &want, DEFAULT_OPTS);
assert_eq!(
plan.changes,
vec![SchemaChange::DropIndex {
table: "posts".to_owned(),
index: idx,
}]
);
}
fn expr_index() -> Index {
Index {
name: "idx_posts_lower_body".to_owned(),
columns: vec!["body".to_owned()],
unique: false,
definition: Some("CREATE INDEX idx_posts_lower_body ON posts (lower(body))".to_owned()),
is_partial: false,
key_columns: Vec::new(),
}
}
#[test]
fn model_diff_retains_baseline_only_definition_index() {
let mut base_table = posts_with(vec![col("body", ColumnType::Text)]);
base_table.indexes.push(expr_index());
let want = parsed(
vec![posts_with(vec![col("body", ColumnType::Text)])],
vec![],
);
let plan = diff_schema(&[base_table], &want, DEFAULT_OPTS);
assert!(
!plan
.changes
.iter()
.any(|c| matches!(c, SchemaChange::DropIndex { .. })),
"model diff must retain (not drop) an unmodellable definition index; got {:?}",
plan.changes
);
}
#[test]
fn model_diff_retains_constraint_owned_unique_index() {
let constraint_idx = Index {
name: "users_email_key".to_owned(),
columns: vec!["email".to_owned()],
unique: true,
definition: Some(
"CREATE UNIQUE INDEX users_email_key ON public.users USING btree (email)"
.to_owned(),
),
is_partial: false,
key_columns: vec!["email".to_owned()],
};
let mut base_table = posts_with(vec![col("email", ColumnType::Text)]);
base_table.indexes.push(constraint_idx);
let want = parsed(
vec![posts_with(vec![col("email", ColumnType::Text)])],
vec![],
);
let plan = diff_schema(&[base_table], &want, DEFAULT_OPTS);
assert!(
!plan
.changes
.iter()
.any(|c| matches!(c, SchemaChange::DropIndex { .. })),
"model diff must retain (not drop) a constraint-owned index; got {:?}",
plan.changes
);
}
#[test]
fn model_diff_existing_unique_index_satisfies_model_unique_by_columns() {
let constraint_idx = Index {
name: "accounts_email_key".to_owned(),
columns: vec!["email".to_owned()],
unique: true,
definition: Some(
"CREATE UNIQUE INDEX accounts_email_key ON public.accounts USING btree (email)"
.to_owned(),
),
is_partial: false,
key_columns: vec!["email".to_owned()],
};
let mut base_table = posts_with(vec![col("email", ColumnType::Text)]);
base_table.indexes.push(constraint_idx);
let mut want_table = posts_with(vec![col("email", ColumnType::Text)]);
want_table.indexes.push(Index {
name: "idx_posts_email_unique".to_owned(),
columns: vec!["email".to_owned()],
unique: true,
definition: None,
is_partial: false,
key_columns: Vec::new(),
});
let plan = diff_schema(
&[base_table],
&parsed(vec![want_table], vec![]),
DEFAULT_OPTS,
);
assert!(
plan.changes.is_empty(),
"existing unique index over the same column set must satisfy the model \
#[unique]; expected a clean plan, got {:?}",
plan.changes
);
assert!(
guard_plan(&plan, DEFAULT_OPTS).is_ok(),
"clean plan must not trip guard_plan"
);
}
#[test]
fn model_diff_retains_differently_named_covering_unique_index() {
let covering = Index {
name: "accounts_email_uq".to_owned(),
columns: vec!["email".to_owned()],
unique: true,
definition: None, is_partial: false,
key_columns: Vec::new(),
};
let mut base_table = posts_with(vec![col("email", ColumnType::Text)]);
base_table.indexes.push(covering);
let mut want_table = posts_with(vec![col("email", ColumnType::Text)]);
want_table.indexes.push(Index {
name: "idx_posts_email_unique".to_owned(),
columns: vec!["email".to_owned()],
unique: true,
definition: None,
is_partial: false,
key_columns: Vec::new(),
});
let plan = diff_schema(
&[base_table],
&parsed(vec![want_table], vec![]),
DEFAULT_OPTS,
);
assert!(
plan.changes.is_empty(),
"a differently-named covering unique index is RETAINED and the model's \
matching AddIndex suppressed (same uniqueness, different name): {:?}",
plan.changes
);
}
#[test]
fn model_diff_unmatched_baseline_unique_index_still_drops() {
let orphan = Index {
name: "posts_nickname_uq".to_owned(),
columns: vec!["nickname".to_owned()],
unique: true,
definition: None,
is_partial: false,
key_columns: Vec::new(),
};
let mut base_table = posts_with(vec![col("nickname", ColumnType::Text)]);
base_table.indexes.push(orphan);
let want_table = posts_with(vec![col("nickname", ColumnType::Text)]);
let plan = diff_schema(
&[base_table],
&parsed(vec![want_table], vec![]),
DEFAULT_OPTS,
);
assert!(
plan.changes.iter().any(|c| matches!(
c,
SchemaChange::DropIndex { index, .. } if index.name == "posts_nickname_uq"
)),
"an unmatched baseline unique index must still drop: {:?}",
plan.changes
);
}
#[test]
fn model_diff_nonunique_baseline_does_not_satisfy_unique_model_index() {
let nonunique = Index {
name: "idx_posts_email".to_owned(),
columns: vec!["email".to_owned()],
unique: false,
definition: None,
is_partial: false,
key_columns: Vec::new(),
};
let mut base_table = posts_with(vec![col("email", ColumnType::Text)]);
base_table.indexes.push(nonunique);
let mut want_table = posts_with(vec![col("email", ColumnType::Text)]);
want_table.indexes.push(Index {
name: "idx_posts_email_unique".to_owned(),
columns: vec!["email".to_owned()],
unique: true,
definition: None,
is_partial: false,
key_columns: Vec::new(),
});
let plan = diff_schema(
&[base_table],
&parsed(vec![want_table], vec![]),
DEFAULT_OPTS,
);
assert!(
plan.changes.iter().any(|c| matches!(
c,
SchemaChange::AddIndex { index, .. } if index.name == "idx_posts_email_unique"
)),
"a unique model index over a column with only a non-unique baseline index \
must still emit AddIndex; got {:?}",
plan.changes
);
}
#[test]
fn authoritative_diff_still_emits_add_for_differently_named_unique_index() {
let constraint_idx = Index {
name: "accounts_email_key".to_owned(),
columns: vec!["email".to_owned()],
unique: true,
definition: Some(
"CREATE UNIQUE INDEX accounts_email_key ON public.accounts USING btree (email)"
.to_owned(),
),
is_partial: false,
key_columns: vec!["email".to_owned()],
};
let mut base_table = posts_with(vec![col("email", ColumnType::Text)]);
base_table.indexes.push(constraint_idx);
let mut want_table = posts_with(vec![col("email", ColumnType::Text)]);
want_table.indexes.push(Index {
name: "idx_posts_email_unique".to_owned(),
columns: vec!["email".to_owned()],
unique: true,
definition: None,
is_partial: false,
key_columns: Vec::new(),
});
let plan = diff_schema(
&[base_table],
&parsed(vec![want_table], vec![]),
AUTHORITATIVE,
);
assert!(
plan.changes.iter().any(|c| matches!(
c,
SchemaChange::AddIndex { index, .. } if index.name == "idx_posts_email_unique"
)),
"authoritative diff must still emit AddIndex for a differently-named unique \
index (column-set suppression is model-diff-only); got {:?}",
plan.changes
);
}
#[test]
fn baseline_partial_unique_index_does_not_satisfy_model_unique() {
let partial = Index {
name: "accounts_email_active_key".to_owned(),
columns: vec!["email".to_owned()],
unique: true,
definition: Some(
"CREATE UNIQUE INDEX accounts_email_active_key ON accounts (email) WHERE active"
.to_owned(),
),
is_partial: true,
key_columns: vec!["email".to_owned()],
};
let mut base = posts_with(vec![col("email", ColumnType::Text)]);
base.indexes.push(partial);
assert!(
!baseline_unique_index_covers(&base, &["email".to_owned()]),
"a partial unique index must NOT satisfy a table-wide #[unique]"
);
let mut want_table = posts_with(vec![col("email", ColumnType::Text)]);
want_table.indexes.push(Index {
name: "idx_posts_email_unique".to_owned(),
columns: vec!["email".to_owned()],
unique: true,
definition: None,
is_partial: false,
key_columns: Vec::new(),
});
let plan = diff_schema(&[base], &parsed(vec![want_table], vec![]), DEFAULT_OPTS);
assert!(
plan.changes.iter().any(|c| matches!(
c,
SchemaChange::AddIndex { index, .. } if index.name == "idx_posts_email_unique"
)),
"partial baseline unique index must not suppress the model AddIndex; got {:?}",
plan.changes
);
}
#[test]
fn baseline_expression_unique_index_does_not_satisfy_model_unique() {
let expr = Index {
name: "accounts_lower_email_key".to_owned(),
columns: vec!["email".to_owned()],
unique: true,
definition: Some(
"CREATE UNIQUE INDEX accounts_lower_email_key ON accounts (lower(email))"
.to_owned(),
),
is_partial: false,
key_columns: Vec::new(),
};
let mut base = posts_with(vec![col("email", ColumnType::Text)]);
base.indexes.push(expr);
assert!(
!baseline_unique_index_covers(&base, &["email".to_owned()]),
"an expression unique index (empty key_columns) must NOT satisfy #[unique]"
);
let mut want_table = posts_with(vec![col("email", ColumnType::Text)]);
want_table.indexes.push(Index {
name: "idx_posts_email_unique".to_owned(),
columns: vec!["email".to_owned()],
unique: true,
definition: None,
is_partial: false,
key_columns: Vec::new(),
});
let plan = diff_schema(&[base], &parsed(vec![want_table], vec![]), DEFAULT_OPTS);
assert!(
plan.changes.iter().any(|c| matches!(
c,
SchemaChange::AddIndex { index, .. } if index.name == "idx_posts_email_unique"
)),
"expression baseline unique index must not suppress the model AddIndex; got {:?}",
plan.changes
);
}
#[test]
fn baseline_full_unique_constraint_index_still_satisfies_model_unique() {
let constraint = Index {
name: "accounts_email_key".to_owned(),
columns: vec!["email".to_owned()],
unique: true,
definition: Some(
"CREATE UNIQUE INDEX accounts_email_key ON accounts (email)".to_owned(),
),
is_partial: false,
key_columns: vec!["email".to_owned()],
};
let mut base = posts_with(vec![col("email", ColumnType::Text)]);
base.indexes.push(constraint);
assert!(
baseline_unique_index_covers(&base, &["email".to_owned()]),
"a full non-partial unique constraint index over the exact key set must satisfy #[unique]"
);
let plain = Index {
name: "idx_accounts_email_unique".to_owned(),
columns: vec!["email".to_owned()],
unique: true,
definition: None,
is_partial: false,
key_columns: Vec::new(),
};
let mut base2 = posts_with(vec![col("email", ColumnType::Text)]);
base2.indexes.push(plain);
assert!(
baseline_unique_index_covers(&base2, &["email".to_owned()]),
"a plain full unique index must satisfy #[unique] via its columns"
);
}
#[test]
fn model_diff_retains_definition_index_even_with_allow_destructive() {
let mut base_table = posts_with(vec![col("body", ColumnType::Text)]);
base_table.indexes.push(expr_index());
let want = parsed(
vec![posts_with(vec![col("body", ColumnType::Text)])],
vec![],
);
let plan = diff_schema(&[base_table], &want, ALLOW);
assert!(
!plan
.changes
.iter()
.any(|c| matches!(c, SchemaChange::DropIndex { .. })),
"--allow-destructive must still retain an unmodellable definition index; got {:?}",
plan.changes
);
}
#[test]
fn model_diff_retains_definition_index_sharing_name_with_plain_desired() {
let base_expr = Index {
name: "idx_posts_body".to_owned(),
columns: vec!["body".to_owned()],
unique: false,
definition: Some("CREATE INDEX idx_posts_body ON posts (lower(body))".to_owned()),
is_partial: false,
key_columns: Vec::new(),
};
let mut base_table = posts_with(vec![col("body", ColumnType::Text)]);
base_table.indexes.push(base_expr);
let mut want_table = posts_with(vec![col("body", ColumnType::Text)]);
want_table.indexes.push(Index {
name: "idx_posts_body".to_owned(),
columns: vec!["body".to_owned()],
unique: false,
definition: None,
is_partial: false,
key_columns: Vec::new(),
});
let plan = diff_schema(
&[base_table],
&parsed(vec![want_table], vec![]),
DEFAULT_OPTS,
);
assert!(
plan.changes.is_empty(),
"model diff must retain the definition index and emit no drop/replace; got {:?}",
plan.changes
);
}
fn desired_posts_email_unique() -> Index {
Index {
name: "idx_posts_email_unique".to_owned(),
columns: vec!["email".to_owned()],
unique: true,
definition: None,
is_partial: false,
key_columns: Vec::new(),
}
}
#[test]
fn model_diff_same_named_partial_unique_does_not_suppress_and_retains_partial() {
let partial = Index {
name: "idx_posts_email_unique".to_owned(),
columns: vec!["email".to_owned()],
unique: true,
definition: Some(
"CREATE UNIQUE INDEX idx_posts_email_unique ON posts (email) \
WHERE email IS NOT NULL"
.to_owned(),
),
is_partial: true,
key_columns: vec!["email".to_owned()],
};
let mut base = posts_with(vec![col("email", ColumnType::Text)]);
base.indexes.push(partial);
let mut want_table = posts_with(vec![col("email", ColumnType::Text)]);
want_table.indexes.push(desired_posts_email_unique());
let plan = diff_schema(&[base], &parsed(vec![want_table], vec![]), DEFAULT_OPTS);
assert!(
plan.changes.iter().any(|c| matches!(
c,
SchemaChange::AddIndex { index, .. }
if index.name == "idx_posts_email_unique" && index.definition.is_none()
)),
"a same-named PARTIAL unique index must not suppress the model's full \
#[unique]; got {:?}",
plan.changes
);
assert!(
!plan
.changes
.iter()
.any(|c| matches!(c, SchemaChange::DropIndex { .. })),
"the retained partial index must not be dropped; got {:?}",
plan.changes
);
}
#[test]
fn model_diff_same_named_expression_unique_does_not_suppress() {
let expr = Index {
name: "idx_posts_email_unique".to_owned(),
columns: vec!["email".to_owned()],
unique: true,
definition: Some(
"CREATE UNIQUE INDEX idx_posts_email_unique ON posts (lower(email))".to_owned(),
),
is_partial: false,
key_columns: Vec::new(),
};
let mut base = posts_with(vec![col("email", ColumnType::Text)]);
base.indexes.push(expr);
let mut want_table = posts_with(vec![col("email", ColumnType::Text)]);
want_table.indexes.push(desired_posts_email_unique());
let plan = diff_schema(&[base], &parsed(vec![want_table], vec![]), DEFAULT_OPTS);
assert!(
plan.changes.iter().any(|c| matches!(
c,
SchemaChange::AddIndex { index, .. }
if index.name == "idx_posts_email_unique" && index.definition.is_none()
)),
"a same-named EXPRESSION unique index must not suppress the model's \
#[unique]; got {:?}",
plan.changes
);
assert!(
!plan
.changes
.iter()
.any(|c| matches!(c, SchemaChange::DropIndex { .. })),
"the retained expression index must not be dropped; got {:?}",
plan.changes
);
}
#[test]
fn model_diff_same_named_full_plain_unique_still_suppresses() {
let plain_index = Index {
name: "idx_posts_email_unique".to_owned(),
columns: vec!["email".to_owned()],
unique: true,
definition: None,
is_partial: false,
key_columns: Vec::new(),
};
let mut base = posts_with(vec![col("email", ColumnType::Text)]);
base.indexes.push(plain_index);
let mut want_table = posts_with(vec![col("email", ColumnType::Text)]);
want_table.indexes.push(desired_posts_email_unique());
let plan = diff_schema(&[base], &parsed(vec![want_table], vec![]), DEFAULT_OPTS);
assert!(
plan.changes.is_empty(),
"a same-named FULL plain unique index over the same key must still \
suppress the model #[unique]; got {:?}",
plan.changes
);
}
#[test]
fn model_diff_same_named_full_constraint_unique_suppresses() {
let constraint = Index {
name: "idx_posts_email_unique".to_owned(),
columns: vec!["email".to_owned()],
unique: true,
definition: Some(
"CREATE UNIQUE INDEX idx_posts_email_unique ON posts USING btree (email)"
.to_owned(),
),
is_partial: false,
key_columns: vec!["email".to_owned()],
};
let mut base = posts_with(vec![col("email", ColumnType::Text)]);
base.indexes.push(constraint);
let mut want_table = posts_with(vec![col("email", ColumnType::Text)]);
want_table.indexes.push(desired_posts_email_unique());
let plan = diff_schema(&[base], &parsed(vec![want_table], vec![]), DEFAULT_OPTS);
assert!(
plan.changes.is_empty(),
"a same-named FULL constraint unique index that fully covers the key \
must suppress the model #[unique] (retained, no add); got {:?}",
plan.changes
);
}
#[test]
fn authoritative_diff_drops_baseline_only_definition_index() {
let mut base_table = posts_with(vec![col("body", ColumnType::Text)]);
base_table.indexes.push(expr_index());
let want = parsed(
vec![posts_with(vec![col("body", ColumnType::Text)])],
vec![],
);
let plan = diff_schema(&[base_table], &want, AUTHORITATIVE);
assert_eq!(
plan.changes,
vec![SchemaChange::DropIndex {
table: "posts".to_owned(),
index: expr_index(),
}],
"authoritative introspection diff must report a dropped definition index as drift"
);
}
#[test]
fn add_check_emitted_but_no_drop_check() {
let check = CheckConstraint {
name: Some("posts_body_len".to_owned()),
expression: "length(body) > 0".to_owned(),
};
let base = vec![posts_with(vec![col("body", ColumnType::Text)])];
let mut want_table = posts_with(vec![col("body", ColumnType::Text)]);
want_table.checks.push(check.clone());
let plan = diff_schema(&base, &parsed(vec![want_table], vec![]), DEFAULT_OPTS);
assert_eq!(
plan.changes,
vec![SchemaChange::AddCheck {
table: "posts".to_owned(),
check,
}]
);
let mut base_table = posts_with(vec![col("body", ColumnType::Text)]);
base_table.checks.push(CheckConstraint {
name: Some("posts_body_len".to_owned()),
expression: "length(body) > 0".to_owned(),
});
let want = parsed(
vec![posts_with(vec![col("body", ColumnType::Text)])],
vec![],
);
let plan = diff_schema(&[base_table], &want, DEFAULT_OPTS);
assert!(
plan.is_empty(),
"baseline-only check must not be dropped: {plan:?}"
);
}
#[test]
fn new_table_creates() {
let base: Vec<Table> = vec![];
let want_table = posts_with(vec![col("body", ColumnType::Text)]);
let plan = diff_schema(
&base,
&parsed(vec![want_table.clone()], vec![]),
DEFAULT_OPTS,
);
assert_eq!(plan.changes, vec![SchemaChange::CreateTable(want_table)]);
}
#[test]
fn missing_table_drops_carrying_baseline() {
let base_table = posts_with(vec![col("body", ColumnType::Text)]);
let want = parsed(vec![], vec![]);
let plan = diff_schema(std::slice::from_ref(&base_table), &want, DEFAULT_OPTS);
assert_eq!(plan.changes, vec![SchemaChange::DropTable(base_table)]);
}
#[test]
fn key_columns_diff_by_name_not_position() {
let base = vec![posts_with(vec![
col("a", ColumnType::Text),
col("b", ColumnType::Int32),
])];
let want = parsed(
vec![posts_with(vec![
col("b", ColumnType::Int32),
col("a", ColumnType::Text),
])],
vec![],
);
let plan = diff_schema(&base, &want, DEFAULT_OPTS);
assert!(plan.is_empty(), "reordered columns → no change: {plan:?}");
}
#[test]
fn baseline_enum_check_not_dropped() {
let mut base_table = posts_with(vec![col("status", ColumnType::Text)]);
base_table.checks.push(CheckConstraint {
name: Some("posts_status_check".to_owned()),
expression: "status IN ('draft','live')".to_owned(),
});
let want = parsed(
vec![posts_with(vec![col("status", ColumnType::Text)])],
vec![],
);
let plan = diff_schema(&[base_table], &want, DEFAULT_OPTS);
assert!(plan.is_empty(), "rule A: enum CHECK not dropped: {plan:?}");
}
#[test]
fn baseline_association_fk_not_dropped() {
let mut author = col("author_id", ColumnType::Int64);
author.references = Some(ForeignKey::new("users", "id"));
let base = vec![posts_with(vec![author])];
let want = parsed(
vec![posts_with(vec![col("author_id", ColumnType::Int64)])],
vec![],
);
let plan = diff_schema(&base, &want, DEFAULT_OPTS);
assert!(
plan.is_empty(),
"rule B: association FK not dropped: {plan:?}"
);
}
#[test]
fn baseline_non_convention_default_not_dropped() {
let mut status = col("status", ColumnType::Text);
status.default = Some(ColumnDefault::Sql("'draft'".to_owned()));
let base = vec![posts_with(vec![status])];
let want = parsed(
vec![posts_with(vec![col("status", ColumnType::Text)])],
vec![],
);
let plan = diff_schema(&base, &want, DEFAULT_OPTS);
assert!(
plan.is_empty(),
"rule C: non-convention default not dropped: {plan:?}"
);
}
#[test]
fn enum_skipped_column_not_dropped_via_diagnostic() {
let base = vec![posts_with(vec![col(
"status",
ColumnType::Enum {
variants: vec!["draft".into(), "live".into()],
},
)])];
let want = parsed(
vec![posts_with(vec![])],
vec![diag("Post", "posts", "status")],
);
let plan = diff_schema(&base, &want, DEFAULT_OPTS);
assert!(
plan.is_empty(),
"rules D+E: parser-skipped column not dropped: {plan:?}"
);
}
#[test]
fn custom_table_name_skipped_column_not_dropped_via_diagnostic() {
let mut base_table = Table::new("app_users", Backend::Postgres);
base_table.managed = true;
let mut id = col("id", ColumnType::Int64);
id.primary_key = true;
base_table.columns.push(id);
base_table.primary_key.push("id".to_owned());
base_table.columns.push(col(
"role",
ColumnType::Enum {
variants: vec!["admin".into(), "member".into()],
},
));
let mut desired_table = Table::new("app_users", Backend::Postgres);
desired_table.managed = true;
let mut did = col("id", ColumnType::Int64);
did.primary_key = true;
desired_table.columns.push(did);
desired_table.primary_key.push("id".to_owned());
let want = parsed(vec![desired_table], vec![diag("User", "app_users", "role")]);
let plan = diff_schema(&[base_table], &want, ALLOW);
assert!(
plan.is_empty(),
"custom-table diagnostic must suppress the DropColumn: {plan:?}"
);
}
#[test]
fn drop_still_fires_without_matching_diagnostic() {
let base = vec![posts_with(vec![col("legacy", ColumnType::Text)])];
let want = parsed(
vec![posts_with(vec![])],
vec![diag("Post", "posts", "something_else")],
);
let plan = diff_schema(&base, &want, DEFAULT_OPTS);
assert_eq!(plan.changes.len(), 1);
assert!(matches!(plan.changes[0], SchemaChange::DropColumn { .. }));
}
#[test]
fn index_on_diagnostic_skipped_column_is_not_dropped() {
let mut base_table = posts_with(vec![col(
"status",
ColumnType::Enum {
variants: vec!["draft".into(), "live".into()],
},
)]);
base_table.indexes.push(Index {
name: "idx_posts_status".to_owned(),
columns: vec!["status".to_owned()],
unique: true,
definition: None,
is_partial: false,
key_columns: Vec::new(),
});
let want = parsed(
vec![posts_with(vec![])],
vec![diag("Post", "posts", "status")],
);
let plan = diff_schema(&[base_table], &want, DEFAULT_OPTS);
assert!(
!plan
.changes
.iter()
.any(|c| matches!(c, SchemaChange::DropIndex { .. })),
"index on a parser-skipped column must not be dropped: {plan:?}"
);
assert!(
plan.is_empty(),
"column and its index both suppressed → empty plan: {plan:?}"
);
}
#[test]
fn index_on_normal_column_still_dropped() {
let idx = Index {
name: "idx_posts_body".to_owned(),
columns: vec!["body".to_owned()],
unique: false,
definition: None,
is_partial: false,
key_columns: Vec::new(),
};
let mut base_table = posts_with(vec![col("body", ColumnType::Text)]);
base_table.indexes.push(idx.clone());
let want = parsed(
vec![posts_with(vec![col("body", ColumnType::Text)])],
vec![],
);
let plan = diff_schema(&[base_table], &want, DEFAULT_OPTS);
assert_eq!(
plan.changes,
vec![SchemaChange::DropIndex {
table: "posts".to_owned(),
index: idx,
}]
);
}
#[test]
fn composite_index_touching_skipped_column_is_not_dropped() {
let mut base_table = posts_with(vec![
col("author_id", ColumnType::Int64),
col(
"status",
ColumnType::Enum {
variants: vec!["draft".into(), "live".into()],
},
),
]);
base_table.indexes.push(Index {
name: "idx_posts_author_status".to_owned(),
columns: vec!["author_id".to_owned(), "status".to_owned()],
unique: true,
definition: None,
is_partial: false,
key_columns: Vec::new(),
});
let want = parsed(
vec![posts_with(vec![col("author_id", ColumnType::Int64)])],
vec![diag("Post", "posts", "status")],
);
let plan = diff_schema(&[base_table], &want, DEFAULT_OPTS);
assert!(
!plan
.changes
.iter()
.any(|c| matches!(c, SchemaChange::DropIndex { .. })),
"composite index touching a skipped column must not be dropped: {plan:?}"
);
}
#[test]
fn create_table_with_diagnostic_skipped_field_is_refused() {
let mut new_table = Table::new("events", Backend::Postgres);
new_table.managed = true;
let mut id = col("id", ColumnType::Int64);
id.primary_key = true;
new_table.columns.push(id);
new_table.primary_key.push("id".to_owned());
let want = parsed(vec![new_table], vec![diag("Event", "events", "kind")]);
let plan = diff_schema(&[], &want, DEFAULT_OPTS);
assert_eq!(
plan.changes,
vec![SchemaChange::CreateTableBlockedBySkippedField {
table: "events".to_owned(),
fields: vec!["kind".to_owned()],
}],
"a diagnostic-skipped new table must not emit CREATE TABLE: {plan:?}"
);
match guard_plan(&plan, DEFAULT_OPTS) {
Err(DiffError::CreateTableWithSkippedField { table, fields }) => {
assert_eq!(table, "events");
assert_eq!(fields, vec!["kind".to_owned()]);
}
other => panic!("expected CreateTableWithSkippedField refusal, got {other:?}"),
}
assert!(matches!(
guard_plan(&plan, ALLOW),
Err(DiffError::CreateTableWithSkippedField { .. })
));
let msg = guard_plan(&plan, DEFAULT_OPTS).unwrap_err().to_string();
assert!(
msg.contains("events") && msg.contains("kind"),
"refusal message names table + field: {msg}"
);
}
#[test]
fn create_table_without_diagnostics_is_allowed() {
let new_table = posts_ref_table("events", col("title", ColumnType::Text));
let want = parsed(vec![new_table.clone()], vec![]);
let plan = diff_schema(&[], &want, DEFAULT_OPTS);
assert_eq!(plan.changes, vec![SchemaChange::CreateTable(new_table)]);
assert!(
guard_plan(&plan, DEFAULT_OPTS).is_ok(),
"a clean new table is emittable"
);
}
#[test]
fn add_fk_to_preexisting_column_is_refused() {
let base = vec![posts_with(vec![col("author_id", ColumnType::Int64)])];
let mut author = col("author_id", ColumnType::Int64);
author.references = Some(ForeignKey::new("users", "id"));
let want = parsed(vec![posts_with(vec![author])], vec![]);
let plan = diff_schema(&base, &want, DEFAULT_OPTS);
assert_eq!(
plan.changes,
vec![SchemaChange::AddForeignKeyToExistingColumn {
table: "posts".to_owned(),
column: "author_id".to_owned(),
}],
"an FK added to a pre-existing column is the refused marker, never an AddForeignKey: \
{plan:?}"
);
assert!(
!plan
.changes
.iter()
.any(|c| matches!(c, SchemaChange::AddForeignKey { .. })),
"must never emit an AddForeignKey on a pre-existing column"
);
let err = guard_plan(&plan, DEFAULT_OPTS).unwrap_err();
assert!(
matches!(err, DiffError::AddForeignKeyToPreexistingColumn { .. }),
"guard refuses the pre-existing-column FK add: {err:?}"
);
assert!(
err.to_string().contains("posts_author_id_fkey"),
"the error explains the constraint-name collision: {err}"
);
assert!(matches!(
guard_plan(&plan, ALLOW).unwrap_err(),
DiffError::AddForeignKeyToPreexistingColumn { .. }
));
}
#[test]
fn add_fk_on_new_column_is_allowed() {
let base = vec![posts_with(vec![])];
let mut author = col("author_id", ColumnType::Int64);
author.nullable = true;
author.references = Some(ForeignKey::new("users", "id"));
let want = parsed(vec![posts_with(vec![author.clone()])], vec![]);
let plan = diff_schema(&base, &want, DEFAULT_OPTS);
assert_eq!(
plan.changes,
vec![SchemaChange::AddColumn {
table: "posts".to_owned(),
column: author,
}],
"a new FK column is a plain AddColumn, not a refused marker: {plan:?}"
);
guard_plan(&plan, DEFAULT_OPTS).expect("a new FK column is emittable");
let up = emit_up_sql(&plan).expect("emit up");
assert!(
up.contains("ADD COLUMN author_id BIGINT NULL REFERENCES users(id)"),
"the new FK column emits its inline REFERENCES: {up}"
);
}
#[test]
fn fk_retarget_is_a_refused_marker_not_a_duplicate_add() {
let mut base_author = col("author_id", ColumnType::Int64);
base_author.references = Some(ForeignKey::new("users", "id"));
let base = vec![posts_with(vec![base_author])];
let mut want_author = col("author_id", ColumnType::Int64);
want_author.references = Some(ForeignKey::new("accounts", "id"));
let want = parsed(vec![posts_with(vec![want_author])], vec![]);
let plan = diff_schema(&base, &want, DEFAULT_OPTS);
assert_eq!(
plan.changes,
vec![SchemaChange::ForeignKeyChange {
table: "posts".to_owned(),
column: "author_id".to_owned(),
from: "users(id)".to_owned(),
to: "accounts(id)".to_owned(),
}],
"an FK retarget is the refused marker, not an AddForeignKey: {plan:?}"
);
assert!(
!plan
.changes
.iter()
.any(|c| matches!(c, SchemaChange::AddForeignKey { .. })),
"must never emit a duplicate AddForeignKey on retarget"
);
let err = guard_plan(&plan, DEFAULT_OPTS).unwrap_err();
assert!(
matches!(err, DiffError::ForeignKeyChange { .. }),
"guard refuses the retarget: {err:?}"
);
assert!(
err.to_string().contains("posts_author_id_fkey"),
"the error explains the constraint-name collision: {err}"
);
assert!(matches!(
guard_plan(&plan, ALLOW).unwrap_err(),
DiffError::ForeignKeyChange { .. }
));
}
#[test]
fn fk_unchanged_target_is_no_change() {
let mut base_author = col("author_id", ColumnType::Int64);
base_author.references = Some(ForeignKey::new("users", "id"));
let mut want_author = col("author_id", ColumnType::Int64);
want_author.references = Some(ForeignKey::new("users", "id"));
let plan = diff_schema(
&[posts_with(vec![base_author])],
&parsed(vec![posts_with(vec![want_author])], vec![]),
DEFAULT_OPTS,
);
assert!(plan.is_empty(), "unchanged FK → empty plan: {plan:?}");
}
#[test]
fn colliding_over_long_fk_names_get_distinct_bounded_names() {
let long_table = "a".repeat(60); let plan = MigrationPlan {
backend: Backend::Postgres,
changes: vec![
SchemaChange::AddForeignKey {
table: long_table.clone(),
column: "author_id".to_owned(),
foreign_key: ForeignKey::new("users", "id"),
},
SchemaChange::AddForeignKey {
table: long_table.clone(),
column: "editor_id".to_owned(),
foreign_key: ForeignKey::new("users", "id"),
},
],
};
guard_plan(&plan, DEFAULT_OPTS).expect("distinct bounded FK names are no longer refused");
let author = bounded_pg_identifier(&format!("{long_table}_author_id_fkey"));
let editor = bounded_pg_identifier(&format!("{long_table}_editor_id_fkey"));
assert!(
author.len() <= PG_MAX_IDENTIFIER_BYTES && editor.len() <= PG_MAX_IDENTIFIER_BYTES,
"both bounded names fit the limit: {author} / {editor}"
);
assert_ne!(
author, editor,
"the hash suffix keeps the two colliding-prefix names distinct"
);
let up = emit_up_sql(&plan).expect("emit up");
assert!(up.contains(&format!("ADD CONSTRAINT {author} ")), "{up}");
assert!(up.contains(&format!("ADD CONSTRAINT {editor} ")), "{up}");
}
#[test]
fn normal_length_fk_names_are_allowed() {
let plan = MigrationPlan {
backend: Backend::Postgres,
changes: vec![SchemaChange::AddForeignKey {
table: "posts".to_owned(),
column: "author_id".to_owned(),
foreign_key: ForeignKey::new("users", "id"),
}],
};
assert!(
guard_plan(&plan, DEFAULT_OPTS).is_ok(),
"normal-length FK names must not be refused"
);
}
#[test]
fn over_long_index_name_is_refused() {
let plan = MigrationPlan {
backend: Backend::Postgres,
changes: vec![SchemaChange::AddIndex {
table: "posts".to_owned(),
index: Index {
name: format!("idx_{}", "x".repeat(70)),
columns: vec!["body".to_owned()],
unique: false,
definition: None,
is_partial: false,
key_columns: Vec::new(),
},
}],
};
let err = guard_plan(&plan, DEFAULT_OPTS).unwrap_err();
assert!(
matches!(err, DiffError::GeneratedIdentifierTooLong { .. }),
"over-long index name is refused: {err:?}"
);
}
#[test]
fn over_long_generated_name_is_allowed_on_sqlite() {
let plan = MigrationPlan {
backend: Backend::Sqlite,
changes: vec![SchemaChange::AddIndex {
table: "posts".to_owned(),
index: Index {
name: format!("idx_{}", "x".repeat(70)),
columns: vec!["body".to_owned()],
unique: false,
definition: None,
is_partial: false,
key_columns: Vec::new(),
},
}],
};
assert!(
guard_plan(&plan, DEFAULT_OPTS).is_ok(),
"the 63-byte guard is Postgres-only"
);
}
#[test]
fn duplicate_generated_index_names_are_refused() {
let dup = "idx_posts_foo_unique".to_owned();
let plan = MigrationPlan {
backend: Backend::Postgres,
changes: vec![
SchemaChange::AddIndex {
table: "posts".to_owned(),
index: Index {
name: dup.clone(),
columns: vec!["foo".to_owned()],
unique: true,
definition: None,
is_partial: false,
key_columns: Vec::new(),
},
},
SchemaChange::AddIndex {
table: "posts".to_owned(),
index: Index {
name: dup.clone(),
columns: vec!["foo_unique".to_owned()],
unique: false,
definition: None,
is_partial: false,
key_columns: Vec::new(),
},
},
],
};
let err = guard_plan(&plan, DEFAULT_OPTS).unwrap_err();
assert!(
matches!(err, DiffError::GeneratedIdentifierTooLong { .. }),
"an exact-duplicate generated index name is refused: {err:?}"
);
assert!(
err.to_string().contains(&dup),
"the error names the duplicated identifier: {err}"
);
assert!(matches!(
guard_plan(&plan, ALLOW).unwrap_err(),
DiffError::GeneratedIdentifierTooLong { .. }
));
}
#[test]
fn duplicate_generated_names_on_create_table_are_refused() {
let dup = "idx_posts_foo_unique".to_owned();
let mut table = posts_with(vec![
col("foo", ColumnType::Text),
col("foo_unique", ColumnType::Text),
]);
table.indexes = vec![
Index {
name: dup.clone(),
columns: vec!["foo".to_owned()],
unique: true,
definition: None,
is_partial: false,
key_columns: Vec::new(),
},
Index {
name: dup.clone(),
columns: vec!["foo_unique".to_owned()],
unique: false,
definition: None,
is_partial: false,
key_columns: Vec::new(),
},
];
let plan = MigrationPlan {
backend: Backend::Postgres,
changes: vec![SchemaChange::CreateTable(table)],
};
let err = guard_plan(&plan, DEFAULT_OPTS).unwrap_err();
assert!(
matches!(err, DiffError::GeneratedIdentifierTooLong { .. }),
"duplicate inline index names on CREATE TABLE are refused: {err:?}"
);
assert!(err.to_string().contains(&dup), "names the duplicate: {err}");
}
#[test]
fn distinct_index_names_are_allowed() {
let plan = MigrationPlan {
backend: Backend::Postgres,
changes: vec![
SchemaChange::AddIndex {
table: "posts".to_owned(),
index: Index {
name: "idx_posts_foo".to_owned(),
columns: vec!["foo".to_owned()],
unique: false,
definition: None,
is_partial: false,
key_columns: Vec::new(),
},
},
SchemaChange::AddIndex {
table: "posts".to_owned(),
index: Index {
name: "idx_posts_bar".to_owned(),
columns: vec!["bar".to_owned()],
unique: false,
definition: None,
is_partial: false,
key_columns: Vec::new(),
},
},
],
};
assert!(
guard_plan(&plan, DEFAULT_OPTS).is_ok(),
"distinct index names must not be refused as a collision"
);
}
#[test]
fn unique_index_add_on_existing_column_is_refused() {
let plan = MigrationPlan {
backend: Backend::Postgres,
changes: vec![SchemaChange::AddIndex {
table: "posts".to_owned(),
index: Index {
name: "idx_posts_email_unique".to_owned(),
columns: vec!["email".to_owned()],
unique: true,
definition: None,
is_partial: false,
key_columns: Vec::new(),
},
}],
};
let err = guard_plan(&plan, DEFAULT_OPTS).unwrap_err();
assert!(
matches!(err, DiffError::UniqueIndexRequiresDedup { .. }),
"a unique index on a pre-existing column is refused: {err:?}"
);
assert!(
err.to_string().contains("idx_posts_email_unique") && err.to_string().contains("posts"),
"the error names the index and table: {err}"
);
assert!(
matches!(
guard_plan(&plan, ALLOW).unwrap_err(),
DiffError::UniqueIndexRequiresDedup { .. }
),
"--allow-destructive does not override an unappliable unique-index add"
);
}
#[test]
fn unique_index_add_on_new_column_is_allowed() {
let plan = MigrationPlan {
backend: Backend::Postgres,
changes: vec![
SchemaChange::AddColumn {
table: "posts".to_owned(),
column: {
let mut c = col("email", ColumnType::Text);
c.nullable = true;
c
},
},
SchemaChange::AddIndex {
table: "posts".to_owned(),
index: Index {
name: "idx_posts_email_unique".to_owned(),
columns: vec!["email".to_owned()],
unique: true,
definition: None,
is_partial: false,
key_columns: Vec::new(),
},
},
],
};
assert!(
guard_plan(&plan, DEFAULT_OPTS).is_ok(),
"a unique index on a newly-added column must not be refused"
);
}
#[test]
fn unique_index_on_new_table_is_allowed() {
let mut table = posts_with(vec![col("email", ColumnType::Text)]);
table.indexes = vec![Index {
name: "idx_posts_email_unique".to_owned(),
columns: vec!["email".to_owned()],
unique: true,
definition: None,
is_partial: false,
key_columns: Vec::new(),
}];
let plan = MigrationPlan {
backend: Backend::Postgres,
changes: vec![SchemaChange::CreateTable(table)],
};
assert!(
guard_plan(&plan, DEFAULT_OPTS).is_ok(),
"a unique index on a brand-new table must not be refused"
);
}
#[test]
fn non_unique_index_add_on_existing_column_still_allowed() {
let plan = MigrationPlan {
backend: Backend::Postgres,
changes: vec![SchemaChange::AddIndex {
table: "posts".to_owned(),
index: Index {
name: "idx_posts_email".to_owned(),
columns: vec!["email".to_owned()],
unique: false,
definition: None,
is_partial: false,
key_columns: Vec::new(),
},
}],
};
assert!(
guard_plan(&plan, DEFAULT_OPTS).is_ok(),
"a non-unique index on a pre-existing column must not be refused"
);
}
fn drop_column_plan() -> MigrationPlan {
MigrationPlan {
backend: Backend::Postgres,
changes: vec![SchemaChange::DropColumn {
table: "users".to_owned(),
column: col("nickname", ColumnType::Text),
}],
}
}
#[test]
fn drop_column_refused_without_flag() {
let err = guard_plan(&drop_column_plan(), DEFAULT_OPTS).unwrap_err();
match err {
DiffError::Destructive { summary, ops } => {
assert!(
summary.contains("users.nickname"),
"names the column: {summary}"
);
assert_eq!(
ops,
vec![DestructiveOp::Column {
table: "users".to_owned(),
column: "nickname".to_owned(),
}]
);
}
other => panic!("expected Destructive, got {other:?}"),
}
}
#[test]
fn drop_column_allowed_with_flag() {
assert!(guard_plan(&drop_column_plan(), ALLOW).is_ok());
}
#[test]
fn drop_table_refused_then_allowed() {
let plan = MigrationPlan {
backend: Backend::Postgres,
changes: vec![SchemaChange::DropTable(posts_with(vec![]))],
};
let err = guard_plan(&plan, DEFAULT_OPTS).unwrap_err();
assert!(matches!(err, DiffError::Destructive { .. }));
assert!(guard_plan(&plan, ALLOW).is_ok());
}
#[test]
fn possible_rename_refused() {
let plan = MigrationPlan {
backend: Backend::Postgres,
changes: vec![
SchemaChange::DropColumn {
table: "users".to_owned(),
column: col("nickname", ColumnType::Text),
},
SchemaChange::AddColumn {
table: "users".to_owned(),
column: col("handle", ColumnType::Text),
},
],
};
let err = guard_plan(&plan, DEFAULT_OPTS).unwrap_err();
match err {
DiffError::PossibleRename {
table,
dropped,
added,
} => {
assert_eq!(table, "users");
assert_eq!(dropped, vec!["nickname".to_owned()]);
assert_eq!(added, vec!["handle".to_owned()]);
assert!(
err_message_mentions_renamed_from(&DiffError::PossibleRename {
table: "users".to_owned(),
dropped: vec!["nickname".to_owned()],
added: vec!["handle".to_owned()],
}),
"message mentions #[renamed_from]"
);
}
other => panic!("expected PossibleRename, got {other:?}"),
}
}
fn err_message_mentions_renamed_from(err: &DiffError) -> bool {
err.to_string().contains("#[renamed_from]")
}
#[test]
fn possible_rename_overridden_by_allow_destructive() {
let mut handle = col("handle", ColumnType::Text);
handle.nullable = true;
let plan = MigrationPlan {
backend: Backend::Postgres,
changes: vec![
SchemaChange::DropColumn {
table: "users".to_owned(),
column: col("nickname", ColumnType::Text),
},
SchemaChange::AddColumn {
table: "users".to_owned(),
column: handle,
},
],
};
assert!(guard_plan(&plan, ALLOW).is_ok());
}
#[test]
fn primary_key_change_refused() {
let base = vec![posts_with(vec![])];
let mut want_table = Table::new("posts", Backend::Postgres);
let mut uuid_id = col("uuid_id", ColumnType::Uuid);
uuid_id.primary_key = true;
want_table.primary_key.push("uuid_id".to_owned());
want_table.columns.push(uuid_id);
let plan = diff_schema(&base, &parsed(vec![want_table], vec![]), DEFAULT_OPTS);
assert_eq!(
plan.changes,
vec![SchemaChange::PrimaryKeyChange {
table: "posts".to_owned(),
}]
);
let err = guard_plan(&plan, DEFAULT_OPTS).unwrap_err();
assert!(matches!(err, DiffError::PrimaryKeyChange { .. }));
let err = guard_plan(&plan, ALLOW).unwrap_err();
assert!(matches!(err, DiffError::PrimaryKeyChange { .. }));
}
#[test]
fn unmanaged_desired_table_not_created() {
let mut t = posts_with(vec![]);
t.managed = false;
let plan = diff_schema(&[], &parsed(vec![t], vec![]), DEFAULT_OPTS);
assert!(
plan.is_empty(),
"unmanaged desired table not created: {plan:?}"
);
}
#[test]
fn unmanaged_baseline_table_not_dropped() {
let mut t = posts_with(vec![]);
t.managed = false;
let plan = diff_schema(&[t], &parsed(vec![], vec![]), DEFAULT_OPTS);
assert!(
plan.is_empty(),
"unmanaged baseline table not dropped: {plan:?}"
);
}
#[test]
fn create_table_bigserial_pk_renders_bigserial() {
let plan = MigrationPlan {
backend: Backend::Postgres,
changes: vec![SchemaChange::CreateTable(posts_with(vec![col(
"body",
ColumnType::Text,
)]))],
};
let up = emit_up_sql(&plan).expect("emit");
assert!(
up.contains("id BIGSERIAL PRIMARY KEY"),
"bigserial PK: {up}"
);
assert!(
!up.contains("id BIGINT"),
"must not render BIGINT for the id PK: {up}"
);
assert!(up.contains("body TEXT NOT NULL"), "body column: {up}");
}
#[test]
fn create_table_uuid_pk_renders_gen_random_uuid() {
let mut t = Table::new("posts", Backend::Postgres);
let mut id = col("id", ColumnType::Uuid);
id.primary_key = true;
id.default = Some(ColumnDefault::Sql("gen_random_uuid()".to_owned()));
t.primary_key.push("id".to_owned());
t.columns.push(id);
let plan = MigrationPlan {
backend: Backend::Postgres,
changes: vec![SchemaChange::CreateTable(t)],
};
let up = emit_up_sql(&plan).expect("emit");
assert!(
up.contains("id UUID PRIMARY KEY DEFAULT gen_random_uuid()"),
"uuid PK: {up}"
);
}
#[test]
fn create_table_uuid_pk_renders_the_v4_expression_on_sqlite() {
let mut t = Table::new("posts", Backend::Sqlite);
let mut id = col("id", ColumnType::Uuid);
id.primary_key = true;
id.default = Some(ColumnDefault::Sql(
autumn_schema_core::SQLITE_UUID_V4_SQL.to_owned(),
));
t.primary_key.push("id".to_owned());
t.columns.push(id);
let plan = MigrationPlan {
backend: Backend::Sqlite,
changes: vec![SchemaChange::CreateTable(t)],
};
let up = emit_up_sql(&plan).expect("emit");
assert!(
up.contains("id TEXT PRIMARY KEY NOT NULL DEFAULT (lower(hex(randomblob(4)))"),
"uuid PK: {up}"
);
assert_eq!(up.matches("randomblob(4)").count(), 1, "{up}");
}
#[test]
fn rebuild_from_an_introspected_sqlite_uuid_key_keeps_the_parenthesized_default() {
let mut t = Table::new("posts", Backend::Sqlite);
let mut id = col("id", ColumnType::Text);
id.primary_key = true;
id.default = Some(ColumnDefault::Sql(
autumn_schema_core::SQLITE_UUID_V4_SQL.to_owned(),
));
t.primary_key.push("id".to_owned());
t.columns.push(id);
assert_eq!(single_pk_column(&t).map(|(_, k)| k), Some(IdKind::Uuid));
let body = render_create_table_body("posts", &t, Backend::Sqlite);
assert!(
body.contains("id TEXT PRIMARY KEY NOT NULL DEFAULT (lower(hex(randomblob(4)))"),
"{body}"
);
let mut other = col("ref", ColumnType::Text);
other.default = Some(ColumnDefault::Sql(
autumn_schema_core::SQLITE_UUID_V4_SQL.to_owned(),
));
let def = render_column_def(&other, Backend::Sqlite, false);
assert!(def.contains("DEFAULT (lower(hex(randomblob(4)))"), "{def}");
}
#[test]
fn pk_kind_for_uuid_accepts_the_sqlite_convention_default() {
let mut conv = col("id", ColumnType::Uuid);
conv.primary_key = true;
conv.default = Some(ColumnDefault::Sql(
autumn_schema_core::SQLITE_UUID_V4_SQL.to_owned(),
));
assert_eq!(pk_kind_for(&conv), Some(IdKind::Uuid));
}
#[test]
fn pk_kind_for_uuid_is_gated_on_the_convention_default() {
let mut conv = col("id", ColumnType::Uuid);
conv.primary_key = true;
conv.default = Some(ColumnDefault::Sql("gen_random_uuid()".to_owned()));
assert_eq!(pk_kind_for(&conv), Some(IdKind::Uuid));
let mut conv_ws = col("id", ColumnType::Uuid);
conv_ws.primary_key = true;
conv_ws.default = Some(ColumnDefault::Sql(" gen_random_uuid() ".to_owned()));
assert_eq!(pk_kind_for(&conv_ws), Some(IdKind::Uuid));
let mut v4 = col("id", ColumnType::Uuid);
v4.primary_key = true;
v4.default = Some(ColumnDefault::Sql("uuid_generate_v4()".to_owned()));
assert_eq!(pk_kind_for(&v4), None);
let mut none = col("id", ColumnType::Uuid);
none.primary_key = true;
assert_eq!(pk_kind_for(&none), None);
}
#[test]
fn pk_kind_for_honors_plain_serial_marker() {
let mut plain = col("id", ColumnType::Int64);
plain.primary_key = true;
plain.serial = Some(SerialKind::Plain);
assert_eq!(pk_kind_for(&plain), None, "a Plain PK is a plain column");
let mut big = col("id", ColumnType::Int64);
big.primary_key = true;
big.serial = Some(SerialKind::BigSerial);
assert_eq!(pk_kind_for(&big), Some(IdKind::BigSerial));
let mut legacy = col("id", ColumnType::Int64);
legacy.primary_key = true; assert_eq!(pk_kind_for(&legacy), Some(IdKind::BigSerial));
}
#[test]
fn plain_pk_reconstructs_without_bigserial_or_autoincrement() {
for (backend, forbidden) in [
(Backend::Postgres, "BIGSERIAL"),
(Backend::Sqlite, "AUTOINCREMENT"),
] {
let mut t = Table::new("ledger", backend);
let mut id = col("id", ColumnType::Int64);
id.primary_key = true;
id.serial = Some(SerialKind::Plain);
t.primary_key.push("id".to_owned());
t.columns.push(id);
let body = render_create_table_body("ledger", &t, backend);
assert!(
!body.contains(forbidden),
"a Plain PK must not render {forbidden} on {backend:?}: {body}"
);
assert!(
body.contains("PRIMARY KEY (id)"),
"a Plain PK renders a table-level primary key on {backend:?}: {body}"
);
}
}
#[test]
fn sqlite_type_comparison_is_affinity_aware_but_pg_stays_exact() {
for (a, b) in [
(ColumnType::Int32, ColumnType::Int64),
(ColumnType::Int64, ColumnType::Bool),
(ColumnType::Float32, ColumnType::Float64),
(ColumnType::Text, ColumnType::Timestamp),
(ColumnType::Timestamp, ColumnType::TimestampTz),
] {
assert!(
column_types_equivalent(&a, &b, Backend::Sqlite),
"{a:?} and {b:?} share a SQLite affinity class"
);
assert!(
!column_types_equivalent(&a, &b, Backend::Postgres),
"{a:?} vs {b:?} must still differ on Postgres"
);
}
assert!(!column_types_equivalent(
&ColumnType::Int64,
&ColumnType::Text,
Backend::Sqlite
));
assert!(!column_types_equivalent(
&ColumnType::Bytes,
&ColumnType::Text,
Backend::Sqlite
));
let a = ColumnType::Opaque {
pg_type: "citext".to_owned(),
};
let b = ColumnType::Opaque {
pg_type: "hstore".to_owned(),
};
assert!(!column_types_equivalent(&a, &b, Backend::Sqlite));
assert!(column_types_equivalent(&a, &a.clone(), Backend::Sqlite));
}
#[test]
fn attachment_and_json_are_equivalent_on_both_backends_despite_introspection_ambiguity() {
assert!(column_types_equivalent(
&ColumnType::Attachment,
&ColumnType::Json,
Backend::Postgres
));
assert!(column_types_equivalent(
&ColumnType::Json,
&ColumnType::Attachment,
Backend::Postgres
));
assert!(column_types_equivalent(
&ColumnType::Attachment,
&ColumnType::Json,
Backend::Sqlite
));
assert!(!column_types_equivalent(
&ColumnType::Attachment,
&ColumnType::Text,
Backend::Postgres
));
assert!(!column_types_equivalent(
&ColumnType::Json,
&ColumnType::Text,
Backend::Postgres
));
}
#[test]
fn pg_diff_reports_no_alter_between_attachment_and_json() {
let mut base = Table::new("posts", Backend::Postgres);
base.managed = true;
base.columns.push(col("meta", ColumnType::Attachment));
let mut want = Table::new("posts", Backend::Postgres);
want.managed = true;
want.columns.push(col("meta", ColumnType::Json));
let plan = diff_schema(
std::slice::from_ref(&base),
&parsed(vec![want], vec![]),
AUTHORITATIVE,
);
assert!(
!plan
.changes
.iter()
.any(|c| matches!(c, SchemaChange::AlterColumnType { .. })),
"Attachment vs Json on the same JSONB column must not drift: {:?}",
plan.changes
);
let mut want2 = Table::new("posts", Backend::Postgres);
want2.managed = true;
want2.columns.push(col("meta", ColumnType::Text));
let plan2 = diff_schema(&[base], &parsed(vec![want2], vec![]), AUTHORITATIVE);
assert!(
plan2
.changes
.iter()
.any(|c| matches!(c, SchemaChange::AlterColumnType { .. })),
"a genuine type change must still drift on Postgres: {:?}",
plan2.changes
);
}
#[test]
fn sqlite_diff_no_alter_within_affinity_class_but_drifts_across() {
let mut base = Table::new("posts", Backend::Sqlite);
base.managed = true;
base.columns.push(col("views", ColumnType::Int64));
let mut want = Table::new("posts", Backend::Sqlite);
want.managed = true;
want.columns.push(col("views", ColumnType::Int32));
let plan = diff_schema(
std::slice::from_ref(&base),
&parsed(vec![want], vec![]),
AUTHORITATIVE,
);
assert!(
!plan
.changes
.iter()
.any(|c| matches!(c, SchemaChange::AlterColumnType { .. })),
"same-class INTEGER change must not drift on SQLite: {:?}",
plan.changes
);
let mut want2 = Table::new("posts", Backend::Sqlite);
want2.managed = true;
want2.columns.push(col("views", ColumnType::Text));
let plan2 = diff_schema(&[base], &parsed(vec![want2], vec![]), AUTHORITATIVE);
assert!(
plan2
.changes
.iter()
.any(|c| matches!(c, SchemaChange::AlterColumnType { .. })),
"a cross-class change must still drift on SQLite: {:?}",
plan2.changes
);
}
#[test]
fn create_table_convention_uuid_pk_renders_id_kind_shape() {
let mut t = Table::new("sessions", Backend::Postgres);
let mut id = col("id", ColumnType::Uuid);
id.primary_key = true;
id.default = Some(ColumnDefault::Sql("gen_random_uuid()".to_owned()));
t.primary_key.push("id".to_owned());
t.columns.push(id);
let body = render_create_table_body("sessions", &t, Backend::Postgres);
assert!(
body.contains("id UUID PRIMARY KEY DEFAULT gen_random_uuid()"),
"convention UUID PK renders the IdKind::Uuid shape: {body}"
);
assert_eq!(
body.matches("gen_random_uuid()").count(),
1,
"the convention default is emitted exactly once: {body}"
);
}
#[test]
fn create_table_uuid_pk_without_default_renders_ordinary_column_with_pk() {
let mut t = Table::new("tokens", Backend::Postgres);
let mut id = col("id", ColumnType::Uuid);
id.primary_key = true;
t.primary_key.push("id".to_owned());
t.columns.push(id);
t.columns.push(col("label", ColumnType::Text));
let body = render_create_table_body("tokens", &t, Backend::Postgres);
assert!(
body.contains("id UUID NOT NULL"),
"no-default UUID PK renders as an ordinary UUID column: {body}"
);
assert!(
!body.contains("gen_random_uuid()"),
"no default must be invented on recreation: {body}"
);
assert!(
body.contains("PRIMARY KEY (id)"),
"the PK is still expressed via the table-level clause: {body}"
);
}
#[test]
fn create_table_uuid_pk_with_non_convention_default_renders_it_verbatim() {
let mut t = Table::new("tokens", Backend::Postgres);
let mut id = col("id", ColumnType::Uuid);
id.primary_key = true;
id.default = Some(ColumnDefault::Sql("uuid_generate_v4()".to_owned()));
t.primary_key.push("id".to_owned());
t.columns.push(id);
let body = render_create_table_body("tokens", &t, Backend::Postgres);
assert!(
body.contains("id UUID NOT NULL DEFAULT uuid_generate_v4()"),
"the real non-convention default renders verbatim: {body}"
);
assert!(
!body.contains("gen_random_uuid()"),
"the convention default is never substituted: {body}"
);
assert!(
body.contains("PRIMARY KEY (id)"),
"the PK is still expressed via the table-level clause: {body}"
);
}
#[test]
fn create_table_int4_serial_pk_renders_serial() {
let mut t = Table::new("counters", Backend::Postgres);
let mut id = col("id", ColumnType::Int32);
id.primary_key = true;
id.default = Some(ColumnDefault::Sql(
"nextval('counters_id_seq'::regclass)".to_owned(),
));
t.primary_key.push("id".to_owned());
t.columns.push(id);
t.columns.push(col("label", ColumnType::Text));
let body = render_create_table_body("counters", &t, Backend::Postgres);
assert!(
body.contains("id SERIAL PRIMARY KEY"),
"int4 SERIAL PK renders SERIAL: {body}"
);
assert!(
!body.contains("nextval") && !body.contains("id INTEGER"),
"the explicit nextval default is suppressed (no INTEGER … DEFAULT nextval): {body}"
);
}
#[test]
fn create_table_int4_pk_without_default_renders_plain_integer() {
let mut t = Table::new("counters", Backend::Postgres);
let mut id = col("id", ColumnType::Int32);
id.primary_key = true;
t.primary_key.push("id".to_owned());
t.columns.push(id);
let body = render_create_table_body("counters", &t, Backend::Postgres);
assert!(
!body.contains("SERIAL"),
"a plain int4 PK must not become SERIAL: {body}"
);
assert!(
body.contains("id INTEGER NOT NULL") && body.contains("PRIMARY KEY (id)"),
"a plain int4 PK renders a plain INTEGER column with a table-level PRIMARY KEY: {body}"
);
}
#[test]
fn create_table_int8_pk_still_renders_bigserial() {
let t = posts_with(vec![col("body", ColumnType::Text)]);
let body = render_create_table_body("posts", &t, Backend::Postgres);
assert!(
body.contains("id BIGSERIAL PRIMARY KEY"),
"int8 PK renders BIGSERIAL: {body}"
);
}
#[test]
fn create_table_composite_pk_uses_table_clause() {
let mut t = Table::new("memberships", Backend::Postgres);
let mut a = col("user_id", ColumnType::Int64);
a.primary_key = true;
let mut b = col("group_id", ColumnType::Int64);
b.primary_key = true;
t.columns.push(a);
t.columns.push(b);
t.primary_key.push("user_id".to_owned());
t.primary_key.push("group_id".to_owned());
let plan = MigrationPlan {
backend: Backend::Postgres,
changes: vec![SchemaChange::CreateTable(t)],
};
let up = emit_up_sql(&plan).expect("emit");
assert!(
up.contains("user_id BIGINT NOT NULL"),
"columns rendered normally: {up}"
);
assert!(
up.contains("PRIMARY KEY (user_id, group_id)"),
"table-level PK: {up}"
);
}
#[test]
fn add_column_not_null_no_default_has_safety_comment() {
let plan = MigrationPlan {
backend: Backend::Postgres,
changes: vec![SchemaChange::AddColumn {
table: "posts".to_owned(),
column: col("title", ColumnType::Text),
}],
};
let up = emit_up_sql(&plan).expect("emit");
assert!(
up.contains("-- autumn-safety: potentially-blocking"),
"safety comment: {up}"
);
assert!(
up.contains("ALTER TABLE posts ADD COLUMN title TEXT NOT NULL;"),
"{up}"
);
}
#[test]
fn add_column_nullable_no_safety_comment() {
let mut bio = col("bio", ColumnType::Text);
bio.nullable = true;
let plan = MigrationPlan {
backend: Backend::Postgres,
changes: vec![SchemaChange::AddColumn {
table: "posts".to_owned(),
column: bio,
}],
};
let up = emit_up_sql(&plan).expect("emit");
assert!(
!up.contains("autumn-safety"),
"no safety comment for nullable: {up}"
);
assert!(
up.contains("ALTER TABLE posts ADD COLUMN bio TEXT NULL;"),
"{up}"
);
}
#[test]
fn add_not_null_column_without_default_is_refused() {
let plan = MigrationPlan {
backend: Backend::Postgres,
changes: vec![SchemaChange::AddColumn {
table: "posts".to_owned(),
column: col("title", ColumnType::Text),
}],
};
let err = guard_plan(&plan, DEFAULT_OPTS).unwrap_err();
match &err {
DiffError::RequiredColumnWithoutDefault { table, column } => {
assert_eq!(table, "posts");
assert_eq!(column, "title");
}
other => panic!("expected RequiredColumnWithoutDefault, got {other:?}"),
}
assert!(
err.to_string()
.contains("cannot add required column `posts.title`"),
"message: {err}"
);
assert!(
matches!(
guard_plan(&plan, ALLOW).unwrap_err(),
DiffError::RequiredColumnWithoutDefault { .. }
),
"must refuse even with --allow-destructive"
);
}
#[test]
fn add_not_null_column_with_default_is_allowed() {
let mut created = col("created_at", ColumnType::Timestamp);
created.default = Some(ColumnDefault::Now);
let plan = MigrationPlan {
backend: Backend::Postgres,
changes: vec![SchemaChange::AddColumn {
table: "posts".to_owned(),
column: created,
}],
};
guard_plan(&plan, DEFAULT_OPTS).expect("NOT NULL with a default is appliable");
let up = emit_up_sql(&plan).expect("emit");
assert!(
up.contains(
"ALTER TABLE posts ADD COLUMN created_at TIMESTAMP NOT NULL DEFAULT NOW();"
),
"{up}"
);
}
#[test]
fn add_nullable_column_is_allowed() {
let mut bio = col("bio", ColumnType::Text);
bio.nullable = true;
let plan = MigrationPlan {
backend: Backend::Postgres,
changes: vec![SchemaChange::AddColumn {
table: "posts".to_owned(),
column: bio,
}],
};
guard_plan(&plan, DEFAULT_OPTS).expect("nullable add is appliable");
let up = emit_up_sql(&plan).expect("emit");
assert!(
up.contains("ALTER TABLE posts ADD COLUMN bio TEXT NULL;"),
"{up}"
);
}
#[test]
fn create_table_with_not_null_no_default_column_is_not_refused() {
let plan = MigrationPlan {
backend: Backend::Postgres,
changes: vec![SchemaChange::CreateTable(posts_with(vec![col(
"title",
ColumnType::Text,
)]))],
};
guard_plan(&plan, DEFAULT_OPTS).expect("CreateTable with a NOT NULL column is empty-safe");
let up = emit_up_sql(&plan).expect("emit");
assert!(up.contains("title TEXT NOT NULL"), "{up}");
}
#[test]
fn set_not_null_on_existing_column_is_refused() {
let plan = MigrationPlan {
backend: Backend::Postgres,
changes: vec![SchemaChange::SetNotNull {
table: "posts".to_owned(),
column: "bio".to_owned(),
}],
};
let err = guard_plan(&plan, DEFAULT_OPTS).unwrap_err();
match &err {
DiffError::SetNotNullRequiresBackfill { table, column } => {
assert_eq!(table, "posts");
assert_eq!(column, "bio");
}
other => panic!("expected SetNotNullRequiresBackfill, got {other:?}"),
}
assert!(
err.to_string().contains("cannot set `posts.bio` NOT NULL"),
"message: {err}"
);
assert!(
matches!(
guard_plan(&plan, ALLOW).unwrap_err(),
DiffError::SetNotNullRequiresBackfill { .. }
),
"must refuse even with --allow-destructive"
);
}
#[test]
fn drop_not_null_is_allowed() {
let plan = MigrationPlan {
backend: Backend::Postgres,
changes: vec![SchemaChange::DropNotNull {
table: "posts".to_owned(),
column: "bio".to_owned(),
}],
};
guard_plan(&plan, DEFAULT_OPTS).expect("DROP NOT NULL is always appliable");
let up = emit_up_sql(&plan).expect("emit");
assert!(
up.contains("ALTER TABLE posts ALTER COLUMN bio DROP NOT NULL;"),
"{up}"
);
}
#[test]
fn add_column_renders_only_the_column_not_the_index() {
let mut author = col("author_id", ColumnType::Int64);
author.nullable = true;
author.references = Some(ForeignKey::new("users", "id"));
let plan = MigrationPlan {
backend: Backend::Postgres,
changes: vec![SchemaChange::AddColumn {
table: "posts".to_owned(),
column: author,
}],
};
let up = emit_up_sql(&plan).expect("emit");
assert!(up.contains("REFERENCES users(id)"), "fk clause: {up}");
assert!(
!up.contains("CREATE INDEX"),
"the reference index is a separate AddIndex change, not inline: {up}"
);
}
#[test]
fn add_reference_column_emits_index_exactly_once() {
let base = vec![posts_with(vec![])];
let mut author = col("author_id", ColumnType::Int64);
author.nullable = true;
author.references = Some(ForeignKey::new("users", "id"));
let mut want_table = posts_with(vec![author]);
want_table.indexes.push(Index {
name: "idx_posts_author_id".to_owned(),
columns: vec!["author_id".to_owned()],
unique: false,
definition: None,
is_partial: false,
key_columns: Vec::new(),
});
let plan = diff_schema(&base, &parsed(vec![want_table], vec![]), DEFAULT_OPTS);
let up = emit_up_sql(&plan).expect("emit");
assert_eq!(
up.matches("CREATE INDEX idx_posts_author_id").count(),
1,
"the reference index must be emitted exactly once: {up}"
);
assert!(
up.contains("ALTER TABLE posts ADD COLUMN author_id BIGINT NULL REFERENCES users(id);"),
"the column is still added: {up}"
);
}
#[test]
fn alter_type_and_set_not_null_templates() {
let plan = MigrationPlan {
backend: Backend::Postgres,
changes: vec![
SchemaChange::AlterColumnType {
table: "posts".to_owned(),
column: "views".to_owned(),
from: ColumnType::Int32,
to: ColumnType::Int64,
},
SchemaChange::SetNotNull {
table: "posts".to_owned(),
column: "views".to_owned(),
},
],
};
let up = emit_up_sql(&plan).expect("emit");
assert!(
up.contains("ALTER TABLE posts ALTER COLUMN views TYPE BIGINT;"),
"{up}"
);
assert!(
up.contains("ALTER TABLE posts ALTER COLUMN views SET NOT NULL;"),
"{up}"
);
}
#[test]
fn create_unique_index_and_drop_index_templates() {
let up = index_sql(
"posts",
&Index {
name: "idx_posts_slug_unique".to_owned(),
columns: vec!["slug".to_owned()],
unique: true,
definition: None,
is_partial: false,
key_columns: Vec::new(),
},
);
assert_eq!(
up,
"CREATE UNIQUE INDEX idx_posts_slug_unique ON posts (slug);"
);
let plan = MigrationPlan {
backend: Backend::Postgres,
changes: vec![SchemaChange::DropIndex {
table: "posts".to_owned(),
index: Index {
name: "idx_posts_slug".to_owned(),
columns: vec!["slug".to_owned()],
unique: false,
definition: None,
is_partial: false,
key_columns: Vec::new(),
},
}],
};
let sql = emit_up_sql(&plan).expect("emit");
assert_eq!(sql, "DROP INDEX idx_posts_slug;\n");
}
#[test]
fn up_ordering_is_canonical() {
let plan = MigrationPlan {
backend: Backend::Postgres,
changes: vec![
SchemaChange::DropTable(Table::new("legacy", Backend::Postgres)),
SchemaChange::DropColumn {
table: "posts".to_owned(),
column: col("old", ColumnType::Text),
},
SchemaChange::AddIndex {
table: "posts".to_owned(),
index: Index {
name: "idx_posts_new".to_owned(),
columns: vec!["new".to_owned()],
unique: false,
definition: None,
is_partial: false,
key_columns: Vec::new(),
},
},
SchemaChange::AddColumn {
table: "posts".to_owned(),
column: {
let mut c = col("new", ColumnType::Text);
c.nullable = true;
c
},
},
SchemaChange::CreateTable(posts_with(vec![])),
],
};
let up = emit_up_sql(&plan).expect("emit");
let create = up.find("CREATE TABLE posts").expect("create present");
let add = up.find("ADD COLUMN new").expect("add present");
let index = up.find("idx_posts_new").expect("index present");
let drop_col = up.find("DROP COLUMN old").expect("drop col present");
let drop_table = up.find("DROP TABLE legacy").expect("drop table present");
assert!(create < add, "create before add");
assert!(add < index, "add before index");
assert!(index < drop_col, "index before drop col");
assert!(drop_col < drop_table, "drop col before drop table");
}
#[test]
fn replaced_index_drops_before_add_in_up_and_inverts_in_down() {
let old = Index {
name: "idx_posts_slug".to_owned(),
columns: vec!["slug".to_owned()],
unique: false,
definition: None,
is_partial: false,
key_columns: Vec::new(),
};
let new = Index {
name: "idx_posts_slug".to_owned(),
columns: vec!["slug".to_owned()],
unique: true,
definition: None,
is_partial: false,
key_columns: Vec::new(),
};
let mut base_table = posts_with(vec![col("slug", ColumnType::Text)]);
base_table.indexes.push(old);
let mut want_table = posts_with(vec![col("slug", ColumnType::Text)]);
want_table.indexes.push(new);
let plan = diff_schema(
&[base_table],
&parsed(vec![want_table], vec![]),
DEFAULT_OPTS,
);
assert_eq!(plan.changes.len(), 2, "drop+add of the same name: {plan:?}");
let up = emit_up_sql(&plan).expect("emit");
let drop = up
.find("DROP INDEX idx_posts_slug;")
.expect("up drop present");
let create = up
.find("CREATE UNIQUE INDEX idx_posts_slug")
.expect("up create present");
assert!(
drop < create,
"DROP must precede CREATE for a replaced index: {up}"
);
let down = emit_down_sql(&plan).expect("emit");
let d_drop = down
.find("DROP INDEX idx_posts_slug;")
.expect("down drop present");
let d_create = down
.find("CREATE INDEX idx_posts_slug ON posts (slug);")
.expect("down recreate (old, non-unique) present");
assert!(
d_drop < d_create,
"down: drop the new before recreating the old: {down}"
);
}
#[test]
fn unrelated_drop_index_still_after_add_index() {
let plan = MigrationPlan {
backend: Backend::Postgres,
changes: vec![
SchemaChange::DropIndex {
table: "posts".to_owned(),
index: Index {
name: "idx_posts_old".to_owned(),
columns: vec!["old".to_owned()],
unique: false,
definition: None,
is_partial: false,
key_columns: Vec::new(),
},
},
SchemaChange::AddIndex {
table: "posts".to_owned(),
index: Index {
name: "idx_posts_new".to_owned(),
columns: vec!["new".to_owned()],
unique: false,
definition: None,
is_partial: false,
key_columns: Vec::new(),
},
},
],
};
let up = emit_up_sql(&plan).expect("emit");
let add = up.find("CREATE INDEX idx_posts_new").expect("add present");
let drop = up.find("DROP INDEX idx_posts_old;").expect("drop present");
assert!(
add < drop,
"an unrelated add still precedes an unrelated drop: {up}"
);
}
#[test]
fn create_tables_topologically_ordered_by_fk() {
let posts = posts_with(vec![col("body", ColumnType::Text)]);
let mut post_ref = col("post_id", ColumnType::Int64);
post_ref.references = Some(ForeignKey::new("posts", "id"));
let comments = posts_ref_table("comments", post_ref);
let plan = diff_schema(&[], &parsed(vec![comments, posts], vec![]), DEFAULT_OPTS);
let up = emit_up_sql(&plan).expect("emit");
let p = up.find("CREATE TABLE posts").expect("posts present");
let c = up.find("CREATE TABLE comments").expect("comments present");
assert!(
p < c,
"referenced `posts` must be created before referencing `comments`: {up}"
);
let down = emit_down_sql(&plan).expect("emit");
let dp = down.find("DROP TABLE posts").expect("down posts");
let dc = down.find("DROP TABLE comments").expect("down comments");
assert!(
dc < dp,
"down: drop referencing `comments` before referenced `posts`: {down}"
);
}
#[test]
fn create_table_fk_cycle_is_refused() {
let mut a_b = col("b_id", ColumnType::Int64);
a_b.references = Some(ForeignKey::new("b", "id"));
let table_a = posts_ref_table("a", a_b);
let mut b_a = col("a_id", ColumnType::Int64);
b_a.references = Some(ForeignKey::new("a", "id"));
let table_b = posts_ref_table("b", b_a);
let plan = diff_schema(&[], &parsed(vec![table_a, table_b], vec![]), DEFAULT_OPTS);
let err = emit_up_sql(&plan).unwrap_err();
let EmitError::CyclicTableDependencies { tables } = &err else {
panic!("expected CyclicTableDependencies, got {err:?}");
};
assert_eq!(
*tables,
vec!["a".to_owned(), "b".to_owned()],
"names the cycle"
);
}
#[test]
fn down_add_column_inverts_to_drop_clean() {
let mut bio = col("bio", ColumnType::Text);
bio.nullable = true;
let plan = MigrationPlan {
backend: Backend::Postgres,
changes: vec![SchemaChange::AddColumn {
table: "posts".to_owned(),
column: bio,
}],
};
let down = emit_down_sql(&plan).expect("emit");
assert_eq!(down, "ALTER TABLE posts DROP COLUMN bio;\n");
assert!(!down.contains("irreversible"), "clean, no marker: {down}");
}
#[test]
fn down_drop_column_reads_add_with_irreversible_marker() {
let plan = MigrationPlan {
backend: Backend::Postgres,
changes: vec![SchemaChange::DropColumn {
table: "posts".to_owned(),
column: {
let mut c = col("nickname", ColumnType::Text);
c.nullable = true;
c
},
}],
};
let down = emit_down_sql(&plan).expect("emit");
assert!(
down.contains("-- irreversible: column data dropped"),
"irreversible marker: {down}"
);
assert!(
down.contains("ALTER TABLE posts ADD COLUMN nickname TEXT NULL;"),
"re-adds from baseline: {down}"
);
}
#[test]
fn down_drop_table_recreates_with_marker() {
let mut t = posts_with(vec![col("body", ColumnType::Text)]);
t.indexes.push(Index {
name: "idx_posts_body".to_owned(),
columns: vec!["body".to_owned()],
unique: false,
definition: None,
is_partial: false,
key_columns: Vec::new(),
});
let plan = MigrationPlan {
backend: Backend::Postgres,
changes: vec![SchemaChange::DropTable(t)],
};
let down = emit_down_sql(&plan).expect("emit");
assert!(
down.contains("-- irreversible: table data dropped"),
"marker: {down}"
);
assert!(
down.contains("CREATE TABLE posts"),
"recreates the table: {down}"
);
assert!(
down.contains("id BIGSERIAL PRIMARY KEY"),
"recreates PK: {down}"
);
assert!(
down.contains("CREATE INDEX idx_posts_body ON posts (body);"),
"recreates its indexes: {down}"
);
}
#[test]
fn down_alter_type_marked_irreversible() {
let plan = MigrationPlan {
backend: Backend::Postgres,
changes: vec![SchemaChange::AlterColumnType {
table: "posts".to_owned(),
column: "views".to_owned(),
from: ColumnType::Int32,
to: ColumnType::Int64,
}],
};
let down = emit_down_sql(&plan).expect("emit");
assert!(
down.contains("-- irreversible: a narrowing type change"),
"marker: {down}"
);
assert!(
down.contains("ALTER TABLE posts ALTER COLUMN views TYPE INTEGER;"),
"structural inverse restores the `from` type: {down}"
);
}
#[test]
fn down_set_default_from_none_drops_default() {
let plan = MigrationPlan {
backend: Backend::Postgres,
changes: vec![SchemaChange::SetDefault {
table: "posts".to_owned(),
column: "created_at".to_owned(),
to: ColumnDefault::Now,
from: None,
}],
};
let down = emit_down_sql(&plan).expect("emit");
assert_eq!(
down,
"ALTER TABLE posts ALTER COLUMN created_at DROP DEFAULT;\n"
);
}
#[test]
fn down_set_default_from_some_restores_it() {
let plan = MigrationPlan {
backend: Backend::Postgres,
changes: vec![SchemaChange::SetDefault {
table: "posts".to_owned(),
column: "status".to_owned(),
to: ColumnDefault::Sql("'live'".to_owned()),
from: Some(ColumnDefault::Sql("'draft'".to_owned())),
}],
};
let down = emit_down_sql(&plan).expect("emit");
assert_eq!(
down,
"ALTER TABLE posts ALTER COLUMN status SET DEFAULT 'draft';\n"
);
}
#[test]
fn clean_plan_down_has_no_markers() {
let mut bio = col("bio", ColumnType::Text);
bio.nullable = true;
let plan = MigrationPlan {
backend: Backend::Postgres,
changes: vec![
SchemaChange::CreateTable(posts_with(vec![])),
SchemaChange::AddColumn {
table: "posts".to_owned(),
column: bio,
},
SchemaChange::AddIndex {
table: "posts".to_owned(),
index: Index {
name: "idx_posts_bio".to_owned(),
columns: vec!["bio".to_owned()],
unique: false,
definition: None,
is_partial: false,
key_columns: Vec::new(),
},
},
],
};
let down = emit_down_sql(&plan).expect("emit");
assert!(!down.contains("irreversible"), "no markers: {down}");
assert!(!down.contains("manual"), "no markers: {down}");
}
#[test]
fn down_add_check_named_drops_constraint_unnamed_is_manual() {
let named = MigrationPlan {
backend: Backend::Postgres,
changes: vec![SchemaChange::AddCheck {
table: "posts".to_owned(),
check: CheckConstraint {
name: Some("posts_body_len".to_owned()),
expression: "length(body) > 0".to_owned(),
},
}],
};
assert_eq!(
emit_down_sql(&named).expect("emit"),
"ALTER TABLE posts DROP CONSTRAINT posts_body_len;\n"
);
let unnamed = MigrationPlan {
backend: Backend::Postgres,
changes: vec![SchemaChange::AddCheck {
table: "posts".to_owned(),
check: CheckConstraint {
name: None,
expression: "length(body) > 0".to_owned(),
},
}],
};
assert!(
emit_down_sql(&unnamed)
.expect("emit")
.contains("-- manual: unnamed CHECK cannot be auto-dropped"),
"unnamed check → manual note"
);
}
#[test]
fn add_foreign_key_up_and_down() {
let plan = MigrationPlan {
backend: Backend::Postgres,
changes: vec![SchemaChange::AddForeignKey {
table: "posts".to_owned(),
column: "author_id".to_owned(),
foreign_key: ForeignKey::new("users", "id"),
}],
};
let up = emit_up_sql(&plan).expect("emit");
assert_eq!(
up,
"ALTER TABLE posts ADD CONSTRAINT posts_author_id_fkey \
FOREIGN KEY (author_id) REFERENCES users(id);\n"
);
let down = emit_down_sql(&plan).expect("emit");
assert_eq!(
down,
"ALTER TABLE posts DROP CONSTRAINT posts_author_id_fkey;\n"
);
}
fn sqlite_table(name: &str, extra: Vec<Column>, indexes: Vec<Index>) -> Table {
let mut t = Table::new(name, Backend::Sqlite);
let mut id = col("id", ColumnType::Int64);
id.primary_key = true;
t.primary_key.push("id".to_owned());
t.columns.push(id);
t.columns.extend(extra);
t.indexes = indexes;
t
}
fn sqlite_rebuild_fixture() -> (MigrationPlan, SchemaContext) {
let idx = Index {
name: "idx_posts_body".to_owned(),
columns: vec!["body".to_owned()],
unique: false,
definition: None,
is_partial: false,
key_columns: Vec::new(),
};
let baseline = sqlite_table(
"posts",
vec![
col("views", ColumnType::Int32),
col("body", ColumnType::Text),
],
vec![],
);
let desired = sqlite_table(
"posts",
vec![
col("views", ColumnType::Int64),
col("body", ColumnType::Text),
],
vec![idx.clone()],
);
let plan = MigrationPlan {
backend: Backend::Sqlite,
changes: vec![
SchemaChange::AlterColumnType {
table: "posts".to_owned(),
column: "views".to_owned(),
from: ColumnType::Int32,
to: ColumnType::Int64,
},
SchemaChange::AddIndex {
table: "posts".to_owned(),
index: idx,
},
],
};
let ctx = SchemaContext::from_tables(
std::slice::from_ref(&desired),
std::slice::from_ref(&baseline),
);
(plan, ctx)
}
#[test]
fn sqlite_alter_type_renders_table_rebuild_up_golden() {
let (plan, ctx) = sqlite_rebuild_fixture();
let up = emit_up_sql_with_context(&plan, &ctx).expect("emit up");
let expected = "\
-- autumn: SQLite table rebuild for `posts` — ALTER-family changes require a full table recreate.
-- Transaction semantics: this migration must run inside a single transaction (diesel wraps each
-- migration in one). NOTE: `PRAGMA foreign_keys` is a no-op inside a transaction, so the harness
-- must ensure foreign-key enforcement is disabled around the migration.
-- autumn-safety: this recreate copies columns and re-creates indexes only. DROP TABLE drops the table's TRIGGERS (not restored here); VIEWS that reference the table are not dropped and may be left dangling or block the rename — re-create triggers and repair dependent views in a manual migration.
-- autumn-safety: `PRAGMA foreign_key_check` below only REPORTS violation rows (it does not raise); the recreate copies existing values verbatim and introduces no new orphans, but the migration runner must inspect the pragma's output to treat any pre-existing orphan as an error.
PRAGMA foreign_keys=OFF;
-- preserve the AUTOINCREMENT high-water mark so IDs issued-then-deleted are never reused
CREATE TEMP TABLE _autumn_seq_posts AS SELECT seq FROM sqlite_sequence WHERE name = 'posts';
CREATE TABLE posts__autumn_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
views INTEGER NOT NULL,
body TEXT NOT NULL
);
INSERT INTO posts__autumn_new (id, views, body)
SELECT id, views, body FROM posts;
DROP TABLE posts;
PRAGMA legacy_alter_table=ON;
ALTER TABLE posts__autumn_new RENAME TO posts;
PRAGMA legacy_alter_table=OFF;
CREATE INDEX idx_posts_body ON posts (body);
UPDATE sqlite_sequence SET seq = (SELECT seq FROM _autumn_seq_posts)
WHERE name = 'posts' AND (SELECT seq FROM _autumn_seq_posts) IS NOT NULL;
INSERT INTO sqlite_sequence (name, seq)
SELECT 'posts', (SELECT seq FROM _autumn_seq_posts)
WHERE (SELECT seq FROM _autumn_seq_posts) IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM sqlite_sequence WHERE name = 'posts');
DROP TABLE _autumn_seq_posts;
PRAGMA foreign_key_check;
PRAGMA foreign_keys=ON;
";
assert_eq!(up, expected, "SQLite up rebuild golden:\n{up}");
}
#[test]
fn sqlite_alter_type_renders_table_rebuild_down_golden() {
let (plan, ctx) = sqlite_rebuild_fixture();
let down = emit_down_sql_with_context(&plan, &ctx).expect("emit down");
let expected = "\
-- irreversible: a SQLite table rebuild rolled back to the prior shape may not restore data lost by a narrowing type change or dropped column
-- autumn: SQLite table rebuild for `posts` — ALTER-family changes require a full table recreate.
-- Transaction semantics: this migration must run inside a single transaction (diesel wraps each
-- migration in one). NOTE: `PRAGMA foreign_keys` is a no-op inside a transaction, so the harness
-- must ensure foreign-key enforcement is disabled around the migration.
-- autumn-safety: this recreate copies columns and re-creates indexes only. DROP TABLE drops the table's TRIGGERS (not restored here); VIEWS that reference the table are not dropped and may be left dangling or block the rename — re-create triggers and repair dependent views in a manual migration.
-- autumn-safety: `PRAGMA foreign_key_check` below only REPORTS violation rows (it does not raise); the recreate copies existing values verbatim and introduces no new orphans, but the migration runner must inspect the pragma's output to treat any pre-existing orphan as an error.
PRAGMA foreign_keys=OFF;
-- preserve the AUTOINCREMENT high-water mark so IDs issued-then-deleted are never reused
CREATE TEMP TABLE _autumn_seq_posts AS SELECT seq FROM sqlite_sequence WHERE name = 'posts';
CREATE TABLE posts__autumn_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
views INTEGER NOT NULL,
body TEXT NOT NULL
);
INSERT INTO posts__autumn_new (id, views, body)
SELECT id, views, body FROM posts;
DROP TABLE posts;
PRAGMA legacy_alter_table=ON;
ALTER TABLE posts__autumn_new RENAME TO posts;
PRAGMA legacy_alter_table=OFF;
UPDATE sqlite_sequence SET seq = (SELECT seq FROM _autumn_seq_posts)
WHERE name = 'posts' AND (SELECT seq FROM _autumn_seq_posts) IS NOT NULL;
INSERT INTO sqlite_sequence (name, seq)
SELECT 'posts', (SELECT seq FROM _autumn_seq_posts)
WHERE (SELECT seq FROM _autumn_seq_posts) IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM sqlite_sequence WHERE name = 'posts');
DROP TABLE _autumn_seq_posts;
PRAGMA foreign_key_check;
PRAGMA foreign_keys=ON;
";
assert_eq!(down, expected, "SQLite down rebuild golden:\n{down}");
}
#[test]
fn sqlite_down_rebuild_preserves_parser_invisible_column() {
let mut status_baseline = col("status", ColumnType::Text);
status_baseline.default = Some(ColumnDefault::Sql("'draft'".to_owned()));
let baseline = sqlite_table(
"posts",
vec![
col("views", ColumnType::Int32),
col("body", ColumnType::Text),
status_baseline,
],
vec![],
);
let desired = sqlite_table(
"posts",
vec![
col("views", ColumnType::Int64),
col("body", ColumnType::Text),
],
vec![],
);
let plan = MigrationPlan {
backend: Backend::Sqlite,
changes: vec![SchemaChange::AlterColumnType {
table: "posts".to_owned(),
column: "views".to_owned(),
from: ColumnType::Int32,
to: ColumnType::Int64,
}],
};
let ctx = SchemaContext::from_tables(
std::slice::from_ref(&desired),
std::slice::from_ref(&baseline),
);
let down = emit_down_sql_with_context(&plan, &ctx).expect("emit down");
assert!(
down.contains(
"INSERT INTO posts__autumn_new (id, views, body, status)\n \
SELECT id, views, body, status FROM posts;"
),
"the down rollback copies back the parser-invisible retained column: {down}"
);
let up = emit_up_sql_with_context(&plan, &ctx).expect("emit up");
assert!(
up.contains(
"INSERT INTO posts__autumn_new (id, views, body, status)\n \
SELECT id, views, body, status FROM posts;"
),
"the up rebuild also carries the parser-invisible retained column: {up}"
);
}
#[test]
fn postgres_alter_type_still_emits_plain_alter_column() {
let plan = MigrationPlan {
backend: Backend::Postgres,
changes: vec![SchemaChange::AlterColumnType {
table: "posts".to_owned(),
column: "views".to_owned(),
from: ColumnType::Int32,
to: ColumnType::Int64,
}],
};
let up = emit_up_sql(&plan).expect("emit up");
assert_eq!(
up, "ALTER TABLE posts ALTER COLUMN views TYPE BIGINT;\n",
"{up}"
);
assert!(!up.contains("__autumn_new"), "no rebuild on Postgres: {up}");
}
#[test]
fn sqlite_rebuild_coalesces_alter_and_index_into_one_block() {
let (plan, ctx) = sqlite_rebuild_fixture();
let up = emit_up_sql_with_context(&plan, &ctx).expect("emit up");
assert_eq!(
up.matches("CREATE TABLE posts__autumn_new").count(),
1,
"exactly one rebuild block: {up}"
);
assert_eq!(
up.matches("CREATE INDEX idx_posts_body").count(),
1,
"the index is created exactly once: {up}"
);
let rename = up.find("RENAME TO posts").expect("rename present");
let index = up
.find("CREATE INDEX idx_posts_body ON posts (body);")
.expect("index recreated");
assert!(
index > rename,
"the index is recreated as part of the rebuild, after the rename: {up}"
);
}
#[test]
fn sqlite_rebuild_copies_only_surviving_columns() {
let mut legacy = col("legacy", ColumnType::Text);
legacy.nullable = true;
let baseline = sqlite_table(
"posts",
vec![col("views", ColumnType::Int32), legacy.clone()],
vec![],
);
let desired = sqlite_table("posts", vec![col("views", ColumnType::Int64)], vec![]);
let plan = MigrationPlan {
backend: Backend::Sqlite,
changes: vec![
SchemaChange::AlterColumnType {
table: "posts".to_owned(),
column: "views".to_owned(),
from: ColumnType::Int32,
to: ColumnType::Int64,
},
SchemaChange::DropColumn {
table: "posts".to_owned(),
column: legacy,
},
],
};
let ctx = SchemaContext::from_tables(
std::slice::from_ref(&desired),
std::slice::from_ref(&baseline),
);
let up = emit_up_sql_with_context(&plan, &ctx).expect("emit up");
assert!(
up.contains(
"INSERT INTO posts__autumn_new (id, views)\n SELECT id, views FROM posts;"
),
"copies only the surviving columns: {up}"
);
assert!(
!up.contains("legacy TEXT"),
"dropped column is absent from the recreated shape: {up}"
);
}
#[test]
fn sqlite_rebuild_preserves_parser_invisible_baseline_default() {
let mut status_baseline = col("status", ColumnType::Text);
status_baseline.default = Some(ColumnDefault::Sql("'draft'".to_owned()));
let mut baseline = sqlite_table(
"posts",
vec![col("views", ColumnType::Int32), status_baseline],
vec![],
);
baseline.checks.push(CheckConstraint {
name: None,
expression: "length(status) > 0".to_owned(),
});
let desired = sqlite_table(
"posts",
vec![
col("views", ColumnType::Int64),
col("status", ColumnType::Text),
],
vec![],
);
let plan = MigrationPlan {
backend: Backend::Sqlite,
changes: vec![SchemaChange::AlterColumnType {
table: "posts".to_owned(),
column: "views".to_owned(),
from: ColumnType::Int32,
to: ColumnType::Int64,
}],
};
let ctx = SchemaContext::from_tables(
std::slice::from_ref(&desired),
std::slice::from_ref(&baseline),
);
let up = emit_up_sql_with_context(&plan, &ctx).expect("emit up");
assert!(
up.contains("status TEXT NOT NULL DEFAULT 'draft'"),
"the parser-invisible baseline default is preserved in the recreate: {up}"
);
assert!(
up.contains("CHECK (length(status) > 0)"),
"the parser-invisible baseline CHECK is preserved in the recreate: {up}"
);
}
#[test]
fn sqlite_rebuild_without_context_is_a_clear_error() {
let (plan, _ctx) = sqlite_rebuild_fixture();
let err = emit_up_sql(&plan).unwrap_err();
assert!(
matches!(err, EmitError::SqliteRebuildUnsupported { .. }),
"missing-context rebuild is a clear error: {err:?}"
);
assert!(
err.to_string().contains("emit_up_sql_with_context"),
"the error directs the caller to the context-aware entry: {err}"
);
}
#[test]
fn sqlite_rebuild_refuses_on_staging_name_collision() {
let (plan, ctx) = sqlite_rebuild_fixture();
let collider = sqlite_table(
"posts__autumn_new",
vec![col("body", ColumnType::Text)],
vec![],
);
let mut ctx = ctx;
ctx.desired.insert(collider.name.clone(), collider.clone());
ctx.baseline.insert(collider.name.clone(), collider);
let err = emit_up_sql_with_context(&plan, &ctx).unwrap_err();
assert!(
matches!(err, EmitError::SqliteRebuildUnsupported { .. }),
"staging-name collision is refused: {err:?}"
);
assert!(
err.to_string().contains("posts__autumn_new") && err.to_string().contains("collides"),
"the error names the colliding staging table: {err}"
);
}
#[test]
fn sqlite_rebuild_preserves_rows_on_real_sqlite() {
use diesel::connection::SimpleConnection as _;
use diesel::prelude::*;
#[derive(QueryableByName)]
struct PostRow {
#[diesel(sql_type = diesel::sql_types::BigInt)]
id: i64,
#[diesel(sql_type = diesel::sql_types::BigInt)]
views: i64,
#[diesel(sql_type = diesel::sql_types::Text)]
body: String,
}
#[derive(QueryableByName)]
struct FkViolation {
#[diesel(sql_type = diesel::sql_types::Text, column_name = "table")]
_child_table: String,
}
#[derive(QueryableByName)]
struct IntegrityRow {
#[diesel(sql_type = diesel::sql_types::Text, column_name = "integrity_check")]
status: String,
}
let mut conn = SqliteConnection::establish(":memory:").expect("open in-memory sqlite");
conn.batch_execute(
"CREATE TABLE posts (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n \
views INTEGER NOT NULL,\n body TEXT NOT NULL\n);\n\
INSERT INTO posts (views, body) VALUES (3, 'hello'), (7, 'world');",
)
.expect("seed the original table");
let (plan, ctx) = sqlite_rebuild_fixture();
let up = emit_up_sql_with_context(&plan, &ctx).expect("emit up");
conn.batch_execute(&up)
.expect("apply the generated rebuild");
let rows: Vec<PostRow> = diesel::sql_query("SELECT id, views, body FROM posts ORDER BY id")
.load(&mut conn)
.expect("query the rebuilt table");
assert_eq!(rows.len(), 2, "both rows survived the rebuild");
assert_eq!(
(rows[0].id, rows[0].views, rows[0].body.as_str()),
(1, 3, "hello")
);
assert_eq!(
(rows[1].id, rows[1].views, rows[1].body.as_str()),
(2, 7, "world")
);
let violations: Vec<FkViolation> = diesel::sql_query("PRAGMA foreign_key_check")
.load(&mut conn)
.expect("run PRAGMA foreign_key_check");
assert!(
violations.is_empty(),
"foreign_key_check reported {} violation row(s)",
violations.len()
);
let integrity: Vec<IntegrityRow> = diesel::sql_query("PRAGMA integrity_check")
.load(&mut conn)
.expect("run PRAGMA integrity_check");
assert_eq!(integrity.len(), 1, "integrity_check returns one row");
assert_eq!(integrity[0].status, "ok", "integrity_check is clean");
}
#[test]
fn sqlite_rebuild_survives_dependent_view() {
use diesel::connection::SimpleConnection as _;
use diesel::prelude::*;
#[derive(QueryableByName)]
struct ViewRow {
#[diesel(sql_type = diesel::sql_types::BigInt)]
id: i64,
#[diesel(sql_type = diesel::sql_types::Text)]
body: String,
}
let mut conn = SqliteConnection::establish(":memory:").expect("open in-memory sqlite");
conn.batch_execute(
"CREATE TABLE posts (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n \
views INTEGER NOT NULL,\n body TEXT NOT NULL\n);\n\
INSERT INTO posts (views, body) VALUES (3, 'hello');\n\
CREATE VIEW posts_v AS SELECT id, body FROM posts;",
)
.expect("seed the original table and a dependent view");
let (plan, ctx) = sqlite_rebuild_fixture();
let up = emit_up_sql_with_context(&plan, &ctx).expect("emit up");
conn.transaction::<_, diesel::result::Error, _>(|conn| conn.batch_execute(&up))
.expect("the rebuild applies inside a transaction with a dependent view present");
let rows: Vec<ViewRow> = diesel::sql_query("SELECT id, body FROM posts_v ORDER BY id")
.load(&mut conn)
.expect("the dependent view is still queryable after the rebuild");
assert_eq!(rows.len(), 1, "the view still resolves one row");
assert_eq!((rows[0].id, rows[0].body.as_str()), (1, "hello"));
}
#[test]
fn sqlite_rebuild_uuid_pk_emits_no_sequence_preservation() {
let uuid_table = |name: &str| -> Table {
let mut t = Table::new(name, Backend::Sqlite);
let mut id = col("id", ColumnType::Uuid);
id.primary_key = true;
t.primary_key.push("id".to_owned());
t.columns.push(id);
t.columns.push(col("body", ColumnType::Text));
t
};
let baseline = uuid_table("posts");
let desired = uuid_table("posts");
assert!(
single_pk_column(&desired).is_none(),
"a default-less UUID PK is not the IdKind::Uuid convention shape"
);
for leg in [RebuildLeg::Up, RebuildLeg::Down] {
let sql = render_sqlite_rebuild("posts", &desired, &baseline, leg, &[], &[]);
assert!(
!sql.contains("sqlite_sequence"),
"no sqlite_sequence statements for a Uuid PK ({leg:?}): {sql}"
);
assert!(
!sql.contains("_autumn_seq_"),
"no high-water temp table for a Uuid PK ({leg:?}): {sql}"
);
}
}
#[test]
fn sqlite_rebuild_preserves_autoincrement_sequence() {
use diesel::connection::SimpleConnection as _;
use diesel::prelude::*;
#[derive(QueryableByName)]
struct BigRow {
#[diesel(sql_type = diesel::sql_types::BigInt, column_name = "v")]
v: i64,
}
#[derive(QueryableByName)]
struct IntegrityRow {
#[diesel(sql_type = diesel::sql_types::Text, column_name = "integrity_check")]
status: String,
}
let mut conn = SqliteConnection::establish(":memory:").expect("open in-memory sqlite");
conn.batch_execute(
"CREATE TABLE posts (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n \
views INTEGER NOT NULL,\n body TEXT NOT NULL\n);\n\
INSERT INTO posts (views, body) VALUES \
(1, 'a'), (2, 'b'), (3, 'c'), (4, 'd'), (5, 'e');",
)
.expect("seed five rows -> ids 1..5");
let seq: Vec<BigRow> =
diesel::sql_query("SELECT seq AS v FROM sqlite_sequence WHERE name = 'posts'")
.load(&mut conn)
.expect("read sqlite_sequence");
assert_eq!(seq.len(), 1, "sqlite_sequence has a row for posts");
assert_eq!(seq[0].v, 5, "high-water is 5 before the delete");
conn.batch_execute("DELETE FROM posts WHERE id > 1;")
.expect("delete ids 2..5");
let (plan, ctx) = sqlite_rebuild_fixture();
let up = emit_up_sql_with_context(&plan, &ctx).expect("emit up");
conn.batch_execute(&up)
.expect("apply the generated rebuild");
conn.batch_execute("INSERT INTO posts (views, body) VALUES (9, 'new');")
.expect("insert a new row after the rebuild");
let max: Vec<BigRow> = diesel::sql_query("SELECT MAX(id) AS v FROM posts")
.load(&mut conn)
.expect("max id after rebuild");
assert_eq!(
max[0].v, 6,
"the next id after the rebuild does not reuse a deleted id"
);
let survivors: Vec<BigRow> =
diesel::sql_query("SELECT COUNT(*) AS v FROM posts WHERE id = 1")
.load(&mut conn)
.expect("count id=1");
assert_eq!(survivors[0].v, 1, "the surviving id=1 row is intact");
let integrity: Vec<IntegrityRow> = diesel::sql_query("PRAGMA integrity_check")
.load(&mut conn)
.expect("run PRAGMA integrity_check");
assert_eq!(integrity.len(), 1, "integrity_check returns one row");
assert_eq!(integrity[0].status, "ok", "integrity_check is clean");
}
#[test]
fn sqlite_rebuild_skips_sequence_when_source_not_autoincrement() {
use diesel::connection::SimpleConnection as _;
use diesel::{Connection as _, RunQueryDsl as _, SqliteConnection};
#[derive(diesel::QueryableByName)]
struct BigRow {
#[diesel(sql_type = diesel::sql_types::BigInt, column_name = "v")]
v: i64,
}
let int32_pk_table = |name: &str| -> Table {
let mut t = Table::new(name, Backend::Sqlite);
let mut id = col("id", ColumnType::Int32);
id.primary_key = true;
t.primary_key.push("id".to_owned());
t.columns.push(id);
t.columns.push(col("views", ColumnType::Int32));
t.columns.push(col("body", ColumnType::Text));
t
};
let baseline = int32_pk_table("posts");
let mut desired = int32_pk_table("posts");
desired.columns[0].ty = ColumnType::Int64;
assert!(
!matches!(single_pk_column(&baseline), Some((_, IdKind::BigSerial))),
"the baseline (copy source) PK is Int32, not BigSerial"
);
assert!(
matches!(single_pk_column(&desired), Some((_, IdKind::BigSerial))),
"the target PK resolves to BigSerial"
);
let plan = MigrationPlan {
backend: Backend::Sqlite,
changes: vec![SchemaChange::AlterColumnType {
table: "posts".to_owned(),
column: "id".to_owned(),
from: ColumnType::Int32,
to: ColumnType::Int64,
}],
};
let ctx = SchemaContext::from_tables(
std::slice::from_ref(&desired),
std::slice::from_ref(&baseline),
);
let up = emit_up_sql_with_context(&plan, &ctx).expect("emit up");
assert!(
!up.contains("sqlite_sequence"),
"no sqlite_sequence statements when the source is not AUTOINCREMENT:\n{up}"
);
assert!(
!up.contains("_autumn_seq_"),
"no high-water temp table when the source is not AUTOINCREMENT:\n{up}"
);
let mut conn = SqliteConnection::establish(":memory:").expect("open in-memory sqlite");
conn.batch_execute(
"CREATE TABLE posts (\n id INTEGER PRIMARY KEY,\n \
views INTEGER NOT NULL,\n body TEXT NOT NULL\n);\n\
INSERT INTO posts (id, views, body) VALUES (1, 9, 'a');",
)
.expect("seed a non-AUTOINCREMENT table");
conn.batch_execute(&up)
.expect("apply the generated rebuild");
let survivors: Vec<BigRow> =
diesel::sql_query("SELECT COUNT(*) AS v FROM posts WHERE id = 1 AND views = 9")
.load(&mut conn)
.expect("count the surviving row");
assert_eq!(
survivors[0].v, 1,
"the seeded id=1 row survives the rebuild"
);
}
#[test]
fn sqlite_create_table_and_add_column_render() {
let mut t = Table::new("posts", Backend::Sqlite);
let mut id = col("id", ColumnType::Int64);
id.primary_key = true;
t.primary_key.push("id".to_owned());
t.columns.push(id);
t.columns.push(col("body", ColumnType::Text));
let mut bio = col("bio", ColumnType::Text);
bio.nullable = true;
let plan = MigrationPlan {
backend: Backend::Sqlite,
changes: vec![
SchemaChange::CreateTable(t),
SchemaChange::AddColumn {
table: "posts".to_owned(),
column: bio,
},
],
};
let up = emit_up_sql(&plan).expect("emit");
assert!(
up.contains("id INTEGER PRIMARY KEY AUTOINCREMENT"),
"sqlite PK: {up}"
);
assert!(up.contains("ADD COLUMN bio TEXT NULL"), "{up}");
}
const PRICE: ColumnType = ColumnType::Decimal {
precision: 10,
scale: 2,
};
fn price_check() -> String {
crate::generate::schema_edit::sqlite_decimal_check("price", 10, 2)
}
#[test]
fn sqlite_decimal_column_renders_the_generator_check() {
let table = sqlite_table("products", vec![col("price", PRICE)], vec![]);
let mut cost = col("cost", PRICE);
cost.nullable = true;
let plan = MigrationPlan {
backend: Backend::Sqlite,
changes: vec![
SchemaChange::CreateTable(table.clone()),
SchemaChange::AddColumn {
table: "products".to_owned(),
column: cost,
},
],
};
let up = emit_up_sql(&plan).expect("emit");
assert!(
up.contains(&format!(" price TEXT NOT NULL {}", price_check())),
"CREATE TABLE carries the generator CHECK: {up}"
);
let cost_check = crate::generate::schema_edit::sqlite_decimal_check("cost", 10, 2);
assert!(
up.contains(&format!("ADD COLUMN cost TEXT NULL {cost_check};")),
"ADD COLUMN carries the generator CHECK: {up}"
);
let mut pg_table = table;
pg_table.backend = Backend::Postgres;
let pg = emit_create_table(&pg_table, Backend::Postgres);
assert!(pg.contains("price NUMERIC(10,2) NOT NULL"), "{pg}");
assert!(!pg.contains("CHECK"), "Postgres needs no CHECK: {pg}");
}
fn decimal_rebuild_tables(price: ColumnType, cost: ColumnType) -> (Table, Table) {
let mut cost_col = col("cost", cost);
cost_col.nullable = true;
let baseline = sqlite_table(
"products",
vec![
col("views", ColumnType::Int64),
col("price", price),
cost_col,
],
vec![],
);
let mut views = col("views", ColumnType::Int64);
views.nullable = true;
let mut cost_want = col(
"cost",
ColumnType::Decimal {
precision: 5,
scale: 0,
},
);
cost_want.nullable = true;
let desired = sqlite_table(
"products",
vec![views, col("price", PRICE), cost_want],
vec![],
);
(baseline, desired)
}
fn rebuild_legs(
baseline: &Table,
desired: &Table,
changes: usize,
) -> [(&'static str, String); 2] {
let plan = diff_schema(
std::slice::from_ref(baseline),
&parsed(vec![desired.clone()], vec![]),
DiffOptions::default(),
);
assert_eq!(plan.changes.len(), changes, "{:?}", plan.changes);
let ctx = SchemaContext::from_tables(
std::slice::from_ref(desired),
std::slice::from_ref(baseline),
);
[
(
"up",
emit_up_sql_with_context(&plan, &ctx).expect("emit up"),
),
(
"down",
emit_down_sql_with_context(&plan, &ctx).expect("emit down"),
),
]
}
#[test]
fn sqlite_rebuild_keeps_the_desired_decimal_check() {
let cost = ColumnType::Decimal {
precision: 5,
scale: 0,
};
let cost_check = crate::generate::schema_edit::sqlite_decimal_check("cost", 5, 0);
for (price, cost, text_baseline) in [
(PRICE, cost, false),
(ColumnType::Text, ColumnType::Text, true),
] {
let (baseline, desired) = decimal_rebuild_tables(price, cost.clone());
for (leg, sql) in rebuild_legs(&baseline, &desired, 1) {
assert!(
sql.contains(&format!(" price TEXT NOT NULL {}", price_check())),
"the {leg} rebuild keeps the `price` CHECK: {sql}"
);
assert!(
sql.contains(&format!(" cost TEXT NULL {cost_check}")),
"the {leg} rebuild keeps the `cost` CHECK: {sql}"
);
assert_eq!(
sql.contains(
"-- autumn-safety: this recreate writes a new decimal CHECK for column(s) price, cost."
),
text_baseline,
"the {leg} rebuild tells the user only when it adds a CHECK: {sql}"
);
}
}
}
#[test]
fn sqlite_decimal_shape_change_is_a_type_change() {
let old = ColumnType::Decimal {
precision: 12,
scale: 2,
};
let old_check = crate::generate::schema_edit::sqlite_decimal_check("price", 12, 2);
let baseline = sqlite_table("products", vec![col("price", old)], vec![]);
let desired = sqlite_table("products", vec![col("price", PRICE)], vec![]);
let [(_, up), (_, down)] = rebuild_legs(&baseline, &desired, 1);
assert!(up.contains(&price_check()), "{up}");
assert!(!up.contains(&old_check), "{up}");
assert!(down.contains(&old_check), "{down}");
assert!(!down.contains(&price_check()), "{down}");
for sql in [&up, &down] {
assert!(
sql.contains("writes a new decimal CHECK for column(s) price."),
"the SQL does not tell the user about the new CHECK: {sql}"
);
}
}
#[test]
fn sqlite_rebuild_refuses_a_skipped_decimal_column() {
let stale = ColumnType::Decimal {
precision: 12,
scale: 2,
};
let skipped = SchemaDiagnostic {
rust_type: "rust_decimal::Decimal".to_owned(),
..diag("Product", "products", "price")
};
for baseline_ty in [stale, ColumnType::Text] {
let baseline = sqlite_table(
"products",
vec![col("views", ColumnType::Int64), col("price", baseline_ty)],
vec![],
);
let mut views = col("views", ColumnType::Int64);
views.nullable = true;
let desired = parsed(
vec![sqlite_table("products", vec![views], vec![])],
vec![skipped.clone()],
);
let plan = diff_schema(
std::slice::from_ref(&baseline),
&desired,
DiffOptions::default(),
);
let ctx = SchemaContext::from_tables(&desired.tables, std::slice::from_ref(&baseline))
.with_skipped_decimals(&desired);
for result in [
emit_up_sql_with_context(&plan, &ctx),
emit_down_sql_with_context(&plan, &ctx),
] {
let err = result.expect_err("the rebuild did not refuse");
assert!(
matches!(err, EmitError::SqliteRebuildUnsupported { .. }),
"{err:?}"
);
assert!(err.to_string().contains("`price`"), "{err}");
}
}
}
#[test]
fn sqlite_rebuild_allows_a_skipped_non_decimal_column() {
let baseline = sqlite_table(
"products",
vec![
col("views", ColumnType::Int64),
col("status", ColumnType::Text),
],
vec![],
);
let mut views = col("views", ColumnType::Int64);
views.nullable = true;
let desired = parsed(
vec![sqlite_table("products", vec![views], vec![])],
vec![SchemaDiagnostic {
rust_type: "ProductStatus".to_owned(),
..diag("Product", "products", "status")
}],
);
let plan = diff_schema(
std::slice::from_ref(&baseline),
&desired,
DiffOptions::default(),
);
let ctx = SchemaContext::from_tables(&desired.tables, std::slice::from_ref(&baseline))
.with_skipped_decimals(&desired);
emit_up_sql_with_context(&plan, &ctx).expect("emit up");
emit_down_sql_with_context(&plan, &ctx).expect("emit down");
}
#[test]
fn sqlite_rebuild_down_leg_keeps_a_changed_column_type() {
let baseline = sqlite_table("products", vec![col("price", ColumnType::Int64)], vec![]);
let desired = sqlite_table("products", vec![col("price", PRICE)], vec![]);
let [(_, up), (_, down)] = rebuild_legs(&baseline, &desired, 1);
assert!(up.contains(&price_check()), "the up leg has no CHECK: {up}");
assert!(up.contains("writes a new decimal CHECK"), "{up}");
assert!(down.contains(" price INTEGER NOT NULL"), "{down}");
assert!(!down.contains("CHECK"), "the down leg has a CHECK: {down}");
}
#[test]
fn sqlite_add_decimal_column_applies_on_real_sqlite() {
use diesel::connection::SimpleConnection as _;
use diesel::prelude::*;
let mut conn = SqliteConnection::establish(":memory:").expect("in-memory sqlite");
conn.batch_execute(
"CREATE TABLE products (id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL);\n\
INSERT INTO products (title) VALUES ('a'), ('b');",
)
.expect("seed");
let mut price = col("price", PRICE);
price.nullable = true;
let up = emit_up_sql(&MigrationPlan {
backend: Backend::Sqlite,
changes: vec![SchemaChange::AddColumn {
table: "products".to_owned(),
column: price,
}],
})
.expect("emit");
conn.batch_execute(&up).expect("apply ADD COLUMN");
let insert = |conn: &mut SqliteConnection, value: &str| {
conn.batch_execute(&format!(
"INSERT INTO products (title, price) VALUES ('c', {value});"
))
.is_ok()
};
assert!(insert(&mut conn, "'19.99'"), "the CHECK rejects '19.99'");
assert!(insert(&mut conn, "NULL"), "the CHECK rejects NULL");
for value in crate::generate::schema_edit::SQLITE_DECIMAL_10_2_REJECTS {
assert!(
!insert(&mut conn, &format!("'{value}'")),
"the CHECK accepts '{value}'"
);
}
assert!(
!insert(&mut conn, "x'31392e3939'"),
"the CHECK accepts a blob"
);
}
#[test]
fn sqlite_add_not_null_without_default_is_unsupported() {
let plan = MigrationPlan {
backend: Backend::Sqlite,
changes: vec![SchemaChange::AddColumn {
table: "posts".to_owned(),
column: col("title", ColumnType::Text),
}],
};
assert!(matches!(
emit_up_sql(&plan).unwrap_err(),
EmitError::UnsupportedOnBackend { .. }
));
}
#[test]
fn down_drops_fk_column_before_dropping_referenced_table() {
let baseline = vec![posts_with(vec![])];
let mut target_id = col("target_id", ColumnType::Int64);
target_id.nullable = true;
target_id.references = Some(ForeignKey::new("target", "id"));
let desired_posts = posts_with(vec![target_id]);
let mut target = Table::new("target", Backend::Postgres);
let mut tid = col("id", ColumnType::Int64);
tid.primary_key = true;
target.primary_key.push("id".to_owned());
target.columns.push(tid);
let desired = parsed(vec![desired_posts, target], vec![]);
let plan = diff_schema(&baseline, &desired, DEFAULT_OPTS);
guard_plan(&plan, DEFAULT_OPTS).expect("emittable");
let down = emit_down_sql(&plan).expect("emit down");
let drop_col = down
.find("ALTER TABLE posts DROP COLUMN target_id")
.expect("down drops the FK column");
let drop_table = down
.find("DROP TABLE target")
.expect("down drops the referenced table");
assert!(
drop_col < drop_table,
"DROP COLUMN target_id must precede DROP TABLE target:\n{down}"
);
}
#[test]
fn over_long_fkey_constraint_name_is_bounded_not_refused() {
let table = "a".repeat(40);
let column = "b".repeat(40);
let raw = format!("{table}_{column}_fkey");
assert!(
raw.len() > PG_MAX_IDENTIFIER_BYTES,
"the raw name is over the limit"
);
let plan = MigrationPlan {
backend: Backend::Postgres,
changes: vec![SchemaChange::AddForeignKey {
table,
column,
foreign_key: ForeignKey::new("targets", "id"),
}],
};
guard_plan(&plan, DEFAULT_OPTS)
.expect("the length guard no longer refuses a bounded FK name");
let bounded = bounded_pg_identifier(&raw);
assert!(
bounded.len() <= PG_MAX_IDENTIFIER_BYTES,
"the bounded name fits the limit: {bounded}"
);
assert_ne!(bounded, raw, "the over-long name is actually shortened");
let up = emit_up_sql(&plan).expect("emit up");
let down = emit_down_sql(&plan).expect("emit down");
assert!(
up.contains(&format!("ADD CONSTRAINT {bounded} ")),
"up uses the bounded name: {up}"
);
assert!(
down.contains(&format!("DROP CONSTRAINT {bounded};")),
"down uses the SAME bounded name: {down}"
);
}
#[test]
fn short_fkey_constraint_name_is_unchanged() {
assert_eq!(
bounded_pg_identifier("posts_author_id_fkey"),
"posts_author_id_fkey"
);
let plan = MigrationPlan {
backend: Backend::Postgres,
changes: vec![SchemaChange::AddForeignKey {
table: "posts".to_owned(),
column: "author_id".to_owned(),
foreign_key: ForeignKey::new("users", "id"),
}],
};
let up = emit_up_sql(&plan).expect("emit up");
assert!(
up.contains(
"ADD CONSTRAINT posts_author_id_fkey FOREIGN KEY (author_id) REFERENCES users(id);"
),
"{up}"
);
let down = emit_down_sql(&plan).expect("emit down");
assert!(
down.contains("DROP CONSTRAINT posts_author_id_fkey;"),
"{down}"
);
}
#[test]
fn describe_plan_lists_each_change() {
let plan = MigrationPlan {
backend: Backend::Postgres,
changes: vec![
SchemaChange::CreateTable(posts_with(vec![])),
SchemaChange::AddColumn {
table: "posts".to_owned(),
column: col("body", ColumnType::Text),
},
],
};
let text = describe_plan(&plan);
assert!(text.contains("postgres"), "names the backend: {text}");
assert!(text.contains("2 change(s)"), "counts changes: {text}");
assert!(text.contains("CREATE TABLE posts"), "{text}");
assert!(text.contains("ADD COLUMN posts.body"), "{text}");
}
#[test]
fn drop_table_with_retained_inbound_fk_is_refused_even_with_allow_destructive() {
let users = Table::new("users", Backend::Postgres);
let mut user_fk = col("user_id", ColumnType::Int64);
user_fk.references = Some(ForeignKey::new("users", "id"));
let posts = posts_ref_table("posts", user_fk);
let baseline = vec![users, posts.clone()];
let plan = diff_schema(&baseline, &parsed(vec![posts], vec![]), ALLOW);
let err = guard_plan(&plan, ALLOW)
.expect_err("retained posts.user_id → users blocks DROP TABLE users");
let msg = err.to_string();
assert!(
msg.contains("users") && msg.contains("posts.user_id"),
"names the dropped table and the retained referencer: {msg}"
);
}
#[test]
fn drop_table_emits_invisible_association_fk_advisory() {
let plan = MigrationPlan {
backend: Backend::Postgres,
changes: vec![SchemaChange::DropTable(posts_with(vec![]))],
};
guard_plan(&plan, ALLOW).expect("a plain DROP TABLE (no visible inbound FK) is allowed");
let up = emit_up_sql(&plan).expect("emit up");
assert!(
up.contains("-- autumn-safety:")
&& up.contains("#[belongs_to(...)]")
&& up.contains("the offline snapshot cannot see"),
"the DROP TABLE carries the invisible-association-FK advisory: {up}"
);
let advisory = up.find("-- autumn-safety:").expect("advisory present");
let stmt = up.find("DROP TABLE posts;").expect("statement present");
assert!(
advisory < stmt,
"advisory comment precedes the DROP TABLE: {up}"
);
}
#[test]
fn drop_table_self_ref_and_co_dropped_ref_do_not_block() {
let mut parent = col("parent_id", ColumnType::Int64);
parent.references = Some(ForeignKey::new("users", "id"));
let users = posts_ref_table("users", parent);
let plan = diff_schema(&[users], &parsed(vec![], vec![]), ALLOW);
guard_plan(&plan, ALLOW).expect("self-referential FK goes away with the table");
}
#[test]
fn desired_side_fk_to_dropped_table_is_refused() {
let users = Table::new("users", Backend::Postgres);
let mut author_fk = col("author_id", ColumnType::Int64);
author_fk.references = Some(ForeignKey::new("users", "id"));
let posts = posts_ref_table("posts", author_fk);
let plan = diff_schema(&[users], &parsed(vec![posts], vec![]), ALLOW);
let err = guard_plan(&plan, ALLOW)
.expect_err("new posts.author_id -> users blocks DROP TABLE users");
let msg = err.to_string();
assert!(
msg.contains("users") && msg.contains("posts.author_id"),
"names the dropped table and the desired-side referencer: {msg}"
);
}
#[test]
fn desired_side_fk_to_retained_table_is_allowed() {
let users = Table::new("users", Backend::Postgres);
let stale = Table::new("stale", Backend::Postgres);
let mut author_fk = col("author_id", ColumnType::Int64);
author_fk.references = Some(ForeignKey::new("users", "id"));
let posts = posts_ref_table("posts", author_fk);
let plan = diff_schema(
&[users.clone(), stale],
&parsed(vec![users, posts], vec![]),
ALLOW,
);
guard_plan(&plan, ALLOW)
.expect("FK to a retained table is fine even while another table is dropped");
}
#[test]
fn non_implicit_type_conversion_is_refused() {
let base = vec![posts_with(vec![col("views", ColumnType::Text)])];
let want = parsed(
vec![posts_with(vec![col("views", ColumnType::Int32)])],
vec![],
);
let plan = diff_schema(&base, &want, DEFAULT_OPTS);
let err = guard_plan(&plan, ALLOW).expect_err("TEXT→INTEGER needs a USING clause");
let msg = err.to_string();
assert!(
msg.contains("USING") && msg.contains("views"),
"explains the USING requirement and names the column: {msg}"
);
}
#[test]
fn implicit_widening_type_conversion_is_allowed() {
let base = vec![posts_with(vec![col("views", ColumnType::Int32)])];
let want = parsed(
vec![posts_with(vec![col("views", ColumnType::Int64)])],
vec![],
);
let plan = diff_schema(&base, &want, DEFAULT_OPTS);
guard_plan(&plan, DEFAULT_OPTS).expect("Int32→Int64 is an implicit widening");
let up = emit_up_sql(&plan).expect("emit");
assert!(
up.contains("ALTER TABLE posts ALTER COLUMN views TYPE BIGINT;"),
"emits the bare ALTER TYPE for the widening: {up}"
);
}
#[test]
fn type_change_on_referencing_fk_column_is_refused() {
let accounts = posts_ref_table("accounts", col("name", ColumnType::Text));
let mut fk_base = col("account_id", ColumnType::Int32);
fk_base.references = Some(ForeignKey::new("accounts", "id"));
let base = vec![accounts.clone(), posts_with(vec![fk_base])];
let mut fk_want = col("account_id", ColumnType::Int64);
fk_want.references = Some(ForeignKey::new("accounts", "id"));
let want = parsed(vec![accounts, posts_with(vec![fk_want])], vec![]);
let plan = diff_schema(&base, &want, ALLOW);
let err = guard_plan(&plan, ALLOW)
.expect_err("type change on a referencing FK column is unappliable");
let msg = err.to_string();
assert!(
msg.contains("posts.account_id") && msg.contains("foreign-key"),
"names the FK column and the FK reason: {msg}"
);
}
#[test]
fn type_change_on_referenced_key_column_is_refused() {
let mut acc_id_base = col("id", ColumnType::Int32);
acc_id_base.primary_key = true;
let mut accounts_base = Table::new("accounts", Backend::Postgres);
accounts_base.primary_key.push("id".to_owned());
accounts_base.columns.push(acc_id_base);
let mut acc_id_want = col("id", ColumnType::Int64);
acc_id_want.primary_key = true;
let mut accounts_want = Table::new("accounts", Backend::Postgres);
accounts_want.primary_key.push("id".to_owned());
accounts_want.columns.push(acc_id_want);
let mut fk = col("account_id", ColumnType::Int64);
fk.references = Some(ForeignKey::new("accounts", "id"));
let posts = posts_ref_table("posts", fk);
let base = vec![accounts_base, posts.clone()];
let want = parsed(vec![accounts_want, posts], vec![]);
let plan = diff_schema(&base, &want, ALLOW);
let err = guard_plan(&plan, ALLOW)
.expect_err("type change on a referenced key column is unappliable");
let msg = err.to_string();
assert!(
msg.contains("accounts.id") && msg.contains("foreign-key"),
"names the referenced key column and the FK reason: {msg}"
);
}
#[test]
fn type_change_on_plain_column_still_allowed() {
let base = vec![posts_with(vec![col("views", ColumnType::Int32)])];
let want = parsed(
vec![posts_with(vec![col("views", ColumnType::Int64)])],
vec![],
);
let plan = diff_schema(&base, &want, DEFAULT_OPTS);
guard_plan(&plan, DEFAULT_OPTS).expect("plain-column widening is not FK-bound");
let up = emit_up_sql(&plan).expect("emit");
assert!(
up.contains("ALTER TABLE posts ALTER COLUMN views TYPE BIGINT;"),
"emits the bare ALTER TYPE for the plain-column widening: {up}"
);
}
fn sqlite_users_table() -> Table {
let mut t = Table::new("users", Backend::Sqlite);
let mut id = col("id", ColumnType::Int64);
id.primary_key = true;
t.primary_key.push("id".to_owned());
t.columns.push(id);
t
}
fn sqlite_posts_with_author(ty: ColumnType) -> Table {
let mut t = Table::new("posts", Backend::Sqlite);
let mut id = col("id", ColumnType::Int64);
id.primary_key = true;
t.primary_key.push("id".to_owned());
t.columns.push(id);
let mut author = col("author_id", ty);
author.nullable = true;
author.references = Some(ForeignKey::new("users", "id"));
t.columns.push(author);
t
}
#[test]
fn sqlite_fk_column_type_change_recreates_with_advisory() {
let baseline = vec![
sqlite_users_table(),
sqlite_posts_with_author(ColumnType::Int64),
];
let desired = vec![
sqlite_users_table(),
sqlite_posts_with_author(ColumnType::Text),
];
let plan = diff_schema(&baseline, &parsed(desired.clone(), vec![]), ALLOW);
assert_eq!(plan.backend, Backend::Sqlite);
assert!(
plan.changes.iter().any(|c| matches!(
c,
SchemaChange::AlterColumnType { table, column, to, .. }
if table == "posts" && column == "author_id" && *to == ColumnType::Text
)),
"the real AlterColumnType (→ Text) rides in the plan: {:?}",
plan.changes
);
assert!(
plan.changes.iter().any(|c| matches!(
c,
SchemaChange::AlterColumnTypeBlockedByFk { table, column }
if table == "posts" && column == "author_id"
)),
"the FK-bound type-change marker is still recorded: {:?}",
plan.changes
);
guard_plan(&plan, ALLOW)
.expect("SQLite FK-column type change flows to the table recreate, not a refusal");
let ctx = SchemaContext::from_tables(&desired, &baseline);
let up = emit_up_sql_with_context(&plan, &ctx).expect("emit sqlite rebuild");
assert!(
up.contains("author_id TEXT NULL REFERENCES users(id)"),
"the recreate expresses author_id with its (changed) type and keeps the \
inline REFERENCES: {up}"
);
assert!(
up.contains(
"-- autumn-safety: foreign-key column(s) author_id have their type \
changed by this recreate; SQLite does not enforce that they still \
match the referenced key's type, so ensure the referenced column \
stays type-compatible (Postgres rejects this change outright, which \
is why it is emitted only for SQLite)."
),
"emits the FK type-consistency advisory naming the column: {up}"
);
let down = emit_down_sql_with_context(&plan, &ctx).expect("emit sqlite down rebuild");
assert!(
down.contains("-- autumn-safety: foreign-key column(s) author_id have their type"),
"the down rebuild also carries the FK type-consistency advisory: {down}"
);
let mut users_pg = Table::new("users", Backend::Postgres);
let mut uid = col("id", ColumnType::Int64);
uid.primary_key = true;
users_pg.primary_key.push("id".to_owned());
users_pg.columns.push(uid);
let mut author_base = col("author_id", ColumnType::Int32);
author_base.nullable = true;
author_base.references = Some(ForeignKey::new("users", "id"));
let mut author_want = col("author_id", ColumnType::Int64);
author_want.nullable = true;
author_want.references = Some(ForeignKey::new("users", "id"));
let pg_baseline = vec![users_pg.clone(), posts_with(vec![author_base])];
let pg_desired = parsed(vec![users_pg, posts_with(vec![author_want])], vec![]);
let pg_plan = diff_schema(&pg_baseline, &pg_desired, ALLOW);
let err = guard_plan(&pg_plan, ALLOW)
.expect_err("Postgres still refuses the FK-column type change");
assert!(
matches!(
err,
DiffError::TypeChangeOnForeignKeyColumn { ref table, ref column }
if table == "posts" && column == "author_id"
),
"Postgres refuses with TypeChangeOnForeignKeyColumn: {err:?}"
);
}
#[test]
fn sqlite_plain_rebuild_omits_fk_type_advisory() {
let mut base = Table::new("posts", Backend::Sqlite);
let mut id = col("id", ColumnType::Int64);
id.primary_key = true;
base.primary_key.push("id".to_owned());
base.columns.push(id);
base.columns.push(col("views", ColumnType::Int32));
let mut want = base.clone();
want.columns[1].ty = ColumnType::Int64;
let baseline = vec![base];
let desired = vec![want];
let plan = diff_schema(&baseline, &parsed(desired.clone(), vec![]), ALLOW);
let ctx = SchemaContext::from_tables(&desired, &baseline);
let up = emit_up_sql_with_context(&plan, &ctx).expect("emit sqlite rebuild");
assert!(
!up.contains("foreign-key column(s)"),
"a rebuild with no FK-type-change marker carries no FK advisory: {up}"
);
}
#[test]
fn sqlite_fk_column_type_change_recreate_applies_in_memory() {
use diesel::connection::SimpleConnection as _;
use diesel::{Connection as _, RunQueryDsl as _, SqliteConnection};
#[derive(diesel::QueryableByName)]
struct BigRow {
#[diesel(sql_type = diesel::sql_types::BigInt, column_name = "v")]
v: i64,
}
#[derive(diesel::QueryableByName)]
struct FkRow {
#[diesel(sql_type = diesel::sql_types::Text, column_name = "table")]
table: String,
}
let baseline = vec![
sqlite_users_table(),
sqlite_posts_with_author(ColumnType::Int32),
];
let desired = vec![
sqlite_users_table(),
sqlite_posts_with_author(ColumnType::Int64),
];
let plan = diff_schema(&baseline, &parsed(desired.clone(), vec![]), ALLOW);
guard_plan(&plan, ALLOW).expect("SQLite FK-column type change is emittable");
let ctx = SchemaContext::from_tables(&desired, &baseline);
let up = emit_up_sql_with_context(&plan, &ctx).expect("emit up");
let mut conn = SqliteConnection::establish(":memory:").expect("open in-memory sqlite");
conn.batch_execute(
"CREATE TABLE users (\n id INTEGER PRIMARY KEY AUTOINCREMENT\n);\n\
CREATE TABLE posts (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n \
author_id BIGINT NULL REFERENCES users(id)\n);\n\
INSERT INTO users (id) VALUES (1);\n\
INSERT INTO posts (id, author_id) VALUES (1, 1);",
)
.expect("seed the referenced + referencing tables and a row");
conn.transaction::<_, diesel::result::Error, _>(|conn| conn.batch_execute(&up))
.expect("apply the FK-column-type-change recreate in a transaction");
let survivors: Vec<BigRow> =
diesel::sql_query("SELECT COUNT(*) AS v FROM posts WHERE id = 1 AND author_id = 1")
.load(&mut conn)
.expect("count the surviving row");
assert_eq!(
survivors[0].v, 1,
"the seeded post row survives the FK-column type-change recreate"
);
let fks: Vec<FkRow> = diesel::sql_query("PRAGMA foreign_key_list(posts)")
.load(&mut conn)
.expect("read posts foreign keys");
assert!(
fks.iter().any(|f| f.table == "users"),
"the recreate preserves the posts.author_id -> users foreign key"
);
}
#[test]
fn drop_fk_column_and_target_table_together_is_allowed_with_allow_destructive() {
let users = Table::new("users", Backend::Postgres);
let mut user_fk = col("user_id", ColumnType::Int64);
user_fk.references = Some(ForeignKey::new("users", "id"));
let baseline = vec![users, posts_with(vec![user_fk])];
let desired = parsed(vec![posts_with(vec![])], vec![]);
let plan = diff_schema(&baseline, &desired, ALLOW);
guard_plan(&plan, ALLOW)
.expect("co-dropping the FK column and its target table is valid SQL");
let up = emit_up_sql(&plan).expect("emit");
let drop_col = up
.find("DROP COLUMN user_id")
.expect("emits DROP COLUMN user_id");
let drop_tbl = up.find("DROP TABLE users").expect("emits DROP TABLE users");
assert!(
drop_col < drop_tbl,
"DROP COLUMN posts.user_id must precede DROP TABLE users:\n{up}"
);
}
#[test]
fn drop_tables_reverse_topologically_ordered_by_fk() {
let accounts = posts_ref_table("accounts", col("name", ColumnType::Text));
let mut acct_fk = col("account_id", ColumnType::Int64);
acct_fk.references = Some(ForeignKey::new("accounts", "id"));
let audit = posts_ref_table("audit_logs", acct_fk);
let baseline = vec![accounts, audit];
let plan = diff_schema(&baseline, &parsed(vec![], vec![]), ALLOW);
guard_plan(&plan, ALLOW).expect("both dropped — no retained inbound reference");
let up = emit_up_sql(&plan).expect("emit");
let referenced = up.find("DROP TABLE accounts").expect("accounts dropped");
let referencing = up
.find("DROP TABLE audit_logs")
.expect("audit_logs dropped");
assert!(
referencing < referenced,
"drop referencing `audit_logs` before referenced `accounts`: {up}"
);
let down = emit_down_sql(&plan).expect("emit");
let d_referenced = down.find("CREATE TABLE accounts").expect("down accounts");
let d_referencing = down
.find("CREATE TABLE audit_logs")
.expect("down audit_logs");
assert!(
d_referenced < d_referencing,
"down recreates referenced `accounts` before referencing `audit_logs`: {down}"
);
}
#[test]
fn drop_tables_fk_cycle_is_refused() {
let mut a_b = col("b_id", ColumnType::Int64);
a_b.references = Some(ForeignKey::new("b", "id"));
let table_a = posts_ref_table("a", a_b);
let mut b_a = col("a_id", ColumnType::Int64);
b_a.references = Some(ForeignKey::new("a", "id"));
let table_b = posts_ref_table("b", b_a);
let plan = diff_schema(&[table_a, table_b], &parsed(vec![], vec![]), ALLOW);
guard_plan(&plan, ALLOW).expect("co-dropped tables never trip the inbound guard");
let err = emit_up_sql(&plan).unwrap_err();
let EmitError::CyclicTableDependencies { tables } = &err else {
panic!("expected CyclicTableDependencies, got {err:?}");
};
assert_eq!(
*tables,
vec!["a".to_owned(), "b".to_owned()],
"names the cycle"
);
}
#[test]
fn index_dependency_detection_is_exact_column_membership() {
let partial = Index {
name: "t_id_email_kind".to_owned(),
columns: vec!["id".to_owned()],
unique: false,
definition: Some(
"CREATE INDEX t_id_email_kind ON t (id) WHERE kind = 'email'".to_owned(),
),
is_partial: false,
key_columns: Vec::new(),
};
assert!(
!index_depends_on_column(&partial, "email", Backend::Postgres),
"a column name in a definition string literal is NOT a dependency (no false positive)"
);
assert!(
index_depends_on_column(&partial, "id", Backend::Postgres),
"the true dependent column (in `columns`) IS a dependency"
);
let expr = Index {
name: "u_lower_email".to_owned(),
columns: vec!["email".to_owned()],
unique: false,
definition: Some("CREATE INDEX u_lower_email ON users (lower(email))".to_owned()),
is_partial: false,
key_columns: Vec::new(),
};
assert!(
index_depends_on_column(&expr, "email", Backend::Postgres),
"the expression-referenced `email` is captured in `columns`"
);
assert!(
!index_depends_on_column(&expr, "mail", Backend::Postgres),
"a column absent from `columns` is not a dependency"
);
let plain = Index {
name: "u_email".to_owned(),
columns: vec!["email".to_owned()],
unique: true,
definition: None,
is_partial: false,
key_columns: Vec::new(),
};
assert!(
index_depends_on_column(&plain, "email", Backend::Postgres),
"columns membership counts as a dependency"
);
}
#[test]
fn sqlite_index_dependency_includes_predicate_and_expression_columns() {
let partial = Index {
name: "t_email_active".to_owned(),
columns: vec!["email".to_owned()],
unique: false,
definition: Some(
"CREATE INDEX t_email_active ON t (email) WHERE deleted_at IS NULL".to_owned(),
),
is_partial: true,
key_columns: Vec::new(),
};
assert!(
index_depends_on_column(&partial, "deleted_at", Backend::Sqlite),
"the WHERE-predicate column is a dependency on SQLite (definition scan)"
);
assert!(
index_depends_on_column(&partial, "email", Backend::Sqlite),
"the key column is still a dependency"
);
assert!(!index_depends_on_column(
&partial,
"deleted",
Backend::Sqlite
));
assert!(!index_depends_on_column(&partial, "at", Backend::Sqlite));
assert!(!index_depends_on_column(
&partial,
"deleted_at",
Backend::Postgres
));
}
#[test]
fn definition_references_column_is_word_bounded_and_case_insensitive() {
let def = "CREATE INDEX i ON t (email) WHERE Deleted_At IS NULL AND kind = 'deleted_at_x'";
assert!(definition_references_column(def, "email"));
assert!(
definition_references_column(def, "deleted_at"),
"case-insensitive match against `Deleted_At`"
);
assert!(definition_references_column(
"CREATE INDEX i ON t (\"email\")",
"email"
));
assert!(!definition_references_column(
"CREATE INDEX i ON t (email_verified)",
"email"
));
assert!(!definition_references_column(def, ""));
}
#[test]
fn drop_column_up_drops_column_and_down_restores_retained_index() {
let mut email_col = col("email", ColumnType::Text);
email_col.nullable = false;
let mut baseline = posts_ref_table("users", email_col);
baseline.indexes.push(Index {
name: "users_email_key".to_owned(),
columns: vec!["email".to_owned()],
unique: true,
definition: Some("CREATE UNIQUE INDEX users_email_key ON users (email)".to_owned()),
is_partial: false,
key_columns: Vec::new(),
});
let desired = posts_ref_table("users", col("keep", ColumnType::Text));
let plan = diff_schema(
std::slice::from_ref(&baseline),
&parsed(vec![desired.clone()], vec![]),
ALLOW,
);
assert!(
plan.changes.iter().any(
|c| matches!(c, SchemaChange::DropColumn { column, .. } if column.name == "email")
),
"plan must drop `email`: {:?}",
plan.changes
);
assert!(
!plan
.changes
.iter()
.any(|c| matches!(c, SchemaChange::DropIndex { index, .. } if index.name == "users_email_key")),
"the retained definition index must NOT get a DropIndex: {:?}",
plan.changes
);
let ctx = SchemaContext::from_tables(
std::slice::from_ref(&desired),
std::slice::from_ref(&baseline),
);
let up = emit_up_sql_with_context(&plan, &ctx).expect("emit up");
let down = emit_down_sql_with_context(&plan, &ctx).expect("emit down");
assert!(
up.contains("ALTER TABLE users DROP COLUMN email"),
"up must drop the column: {up}"
);
assert!(
!up.contains("DROP INDEX users_email_key"),
"up must NOT emit DROP INDEX for the cascade-dropped retained index: {up}"
);
assert!(
down.contains("ADD COLUMN email"),
"down must re-add the column: {down}"
);
assert!(
down.contains("CREATE UNIQUE INDEX users_email_key ON users (email)"),
"down must restore the cascade-dropped retained index: {down}"
);
let readd_at = down.find("ADD COLUMN email").expect("re-add present");
let index_at = down
.find("CREATE UNIQUE INDEX users_email_key")
.expect("index restore present");
assert!(
readd_at < index_at,
"column must be re-added before its dependent index is recreated: {down}"
);
assert_eq!(
down.matches("CREATE UNIQUE INDEX users_email_key").count(),
1,
"the single-column retained index must be recreated exactly once: {down}"
);
}
#[test]
fn drop_two_columns_down_restores_multicol_retained_index_once_after_both() {
let mut baseline = Table::new("users", Backend::Postgres);
let mut id = col("id", ColumnType::Int64);
id.primary_key = true;
baseline.primary_key.push("id".to_owned());
baseline.columns.push(id);
baseline.columns.push(col("keep", ColumnType::Text));
let mut email = col("email", ColumnType::Text);
email.nullable = false;
baseline.columns.push(email);
baseline.columns.push(col("tenant_id", ColumnType::Int64));
baseline.indexes.push(Index {
name: "users_active".to_owned(),
columns: vec!["email".to_owned(), "tenant_id".to_owned()],
unique: false,
definition: Some(
"CREATE INDEX users_active ON users (lower(email)) WHERE tenant_id IS NOT NULL"
.to_owned(),
),
is_partial: false,
key_columns: Vec::new(),
});
let mut desired = Table::new("users", Backend::Postgres);
let mut did = col("id", ColumnType::Int64);
did.primary_key = true;
desired.primary_key.push("id".to_owned());
desired.columns.push(did);
desired.columns.push(col("keep", ColumnType::Text));
let plan = diff_schema(
std::slice::from_ref(&baseline),
&parsed(vec![desired.clone()], vec![]),
ALLOW,
);
assert_eq!(
plan.changes
.iter()
.filter(|c| matches!(c, SchemaChange::DropColumn { .. }))
.count(),
2,
"plan must drop both columns: {:?}",
plan.changes
);
assert!(
!plan
.changes
.iter()
.any(|c| matches!(c, SchemaChange::DropIndex { index, .. } if index.name == "users_active")),
"the retained definition index must NOT get a DropIndex: {:?}",
plan.changes
);
let ctx = SchemaContext::from_tables(
std::slice::from_ref(&desired),
std::slice::from_ref(&baseline),
);
let down = emit_down_sql_with_context(&plan, &ctx).expect("emit down");
let email_at = down.find("ADD COLUMN email").expect("email re-add present");
let tenant_at = down
.find("ADD COLUMN tenant_id")
.expect("tenant_id re-add present");
assert_eq!(
down.matches("CREATE INDEX users_active").count(),
1,
"the multi-column retained index must be recreated exactly once (deduped): {down}"
);
let index_at = down
.find("CREATE INDEX users_active")
.expect("index restore present");
assert!(
email_at < index_at && tenant_at < index_at,
"both dependent columns must be re-added BEFORE the index is recreated: {down}"
);
}
fn cascade_fk() -> ForeignKey {
ForeignKey::new("users", "id")
.with_on_delete(Some(ForeignKeyAction::Cascade))
.with_on_update(Some(ForeignKeyAction::Restrict))
}
#[test]
fn fk_actions_render_on_add_column() {
for (backend, ty) in [(Backend::Postgres, "BIGINT"), (Backend::Sqlite, "INTEGER")] {
let mut author = col("author_id", ColumnType::Int64);
author.nullable = true;
author.references = Some(cascade_fk());
let plan = MigrationPlan {
backend,
changes: vec![SchemaChange::AddColumn {
table: "posts".to_owned(),
column: author,
}],
};
let up = emit_up_sql(&plan).expect("emit");
assert!(
up.contains(&format!(
"ADD COLUMN author_id {ty} NULL REFERENCES users(id) \
ON DELETE CASCADE ON UPDATE RESTRICT;"
)),
"{backend:?}: {up}"
);
}
}
#[test]
fn fk_actions_render_on_create_table() {
let mut author = col("author_id", ColumnType::Int64);
author.references =
Some(ForeignKey::new("users", "id").with_on_delete(Some(ForeignKeyAction::SetDefault)));
let plan = diff_schema(
&[],
&parsed(vec![posts_with(vec![author])], vec![]),
DEFAULT_OPTS,
);
let up = emit_up_sql(&plan).expect("emit");
assert!(
up.contains("author_id BIGINT NOT NULL REFERENCES users(id) ON DELETE SET DEFAULT"),
"{up}"
);
}
#[test]
fn fk_actions_render_on_add_foreign_key() {
let plan = MigrationPlan {
backend: Backend::Postgres,
changes: vec![SchemaChange::AddForeignKey {
table: "posts".to_owned(),
column: "author_id".to_owned(),
foreign_key: cascade_fk(),
}],
};
let up = emit_up_sql(&plan).expect("emit");
assert!(
up.contains(
"FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE CASCADE ON UPDATE RESTRICT;"
),
"{up}"
);
}
#[test]
fn fk_actions_are_restored_by_the_down_of_a_drop_table() {
let mut author = col("author_id", ColumnType::Int64);
author.references = Some(cascade_fk());
let base = vec![posts_with(vec![author])];
let plan = diff_schema(&base, &parsed(vec![], vec![]), ALLOW);
guard_plan(&plan, ALLOW).expect("drop allowed");
let down = emit_down_sql(&plan).expect("emit down");
assert!(
down.contains("REFERENCES users(id) ON DELETE CASCADE ON UPDATE RESTRICT"),
"{down}"
);
}
#[test]
fn fk_action_change_is_refused_with_no_override() {
let mut base_author = col("author_id", ColumnType::Int64);
base_author.references = Some(ForeignKey::new("users", "id"));
let mut want_author = col("author_id", ColumnType::Int64);
want_author.references = Some(cascade_fk());
let plan = diff_schema(
&[posts_with(vec![base_author])],
&parsed(vec![posts_with(vec![want_author])], vec![]),
DEFAULT_OPTS,
);
assert_eq!(
plan.changes,
vec![SchemaChange::ForeignKeyChange {
table: "posts".to_owned(),
column: "author_id".to_owned(),
from: "users(id)".to_owned(),
to: "users(id) ON DELETE CASCADE ON UPDATE RESTRICT".to_owned(),
}]
);
let err = guard_plan(&plan, ALLOW).unwrap_err();
assert!(matches!(err, DiffError::ForeignKeyChange { .. }), "{err:?}");
let msg = err.to_string();
assert!(
msg.contains("`REFERENCES users(id) ON DELETE CASCADE ON UPDATE RESTRICT`"),
"{msg}"
);
assert!(msg.contains("on_delete = \"cascade\""), "{msg}");
assert!(
describe_change(&plan.changes[0]).contains("FOREIGN KEY CHANGE"),
"the plan label names a change, not a retarget"
);
}
#[test]
fn sqlite_recreate_keeps_fk_actions() {
let mut base_posts = sqlite_posts_with_author(ColumnType::Int64);
base_posts.columns[1].references = Some(cascade_fk());
let mut want_posts = base_posts.clone();
let mut body = col("body", ColumnType::Text);
base_posts.columns.push(body.clone());
body.nullable = true;
want_posts.columns.push(body);
let baseline = vec![sqlite_users_table(), base_posts];
let desired = vec![sqlite_users_table(), want_posts];
let plan = diff_schema(&baseline, &parsed(desired.clone(), vec![]), DEFAULT_OPTS);
guard_plan(&plan, DEFAULT_OPTS).expect("drop not null is safe");
let ctx = SchemaContext::from_tables(&desired, &baseline);
let up = emit_up_sql_with_context(&plan, &ctx).expect("emit sqlite rebuild");
assert!(
up.contains(
"author_id INTEGER NULL REFERENCES users(id) ON DELETE CASCADE ON UPDATE RESTRICT"
),
"{up}"
);
}
#[test]
fn sqlite_rebuild_of_a_cascade_target_is_refused() {
for action in [
ForeignKeyAction::Cascade,
ForeignKeyAction::SetNull,
ForeignKeyAction::SetDefault,
] {
let mut posts = sqlite_posts_with_author(ColumnType::Int64);
posts.columns[1].references =
Some(ForeignKey::new("users", "id").with_on_delete(Some(action)));
let mut base_users = sqlite_users_table();
base_users.columns.push(col("name", ColumnType::Text));
let mut want_users = sqlite_users_table();
let mut name = col("name", ColumnType::Text);
name.nullable = true;
want_users.columns.push(name);
let baseline = vec![base_users, posts.clone()];
let desired = vec![want_users, posts];
let plan = diff_schema(&baseline, &parsed(desired.clone(), vec![]), DEFAULT_OPTS);
guard_plan(&plan, DEFAULT_OPTS).expect("drop not null is safe");
let ctx = SchemaContext::from_tables(&desired, &baseline);
for result in [
emit_up_sql_with_context(&plan, &ctx),
emit_down_sql_with_context(&plan, &ctx),
] {
let err = result.expect_err("a rebuild must not fire child actions");
assert!(
matches!(err, EmitError::SqliteRebuildUnsupported { .. }),
"{err:?}"
);
assert!(err.to_string().contains("posts.author_id"), "{err}");
assert!(err.to_string().contains(action.sql()), "{err}");
}
}
}
#[test]
fn sqlite_rebuild_of_a_restrict_target_is_emitted() {
let mut posts = sqlite_posts_with_author(ColumnType::Int64);
posts.columns[1].references =
Some(ForeignKey::new("users", "id").with_on_delete(Some(ForeignKeyAction::Restrict)));
let mut base_users = sqlite_users_table();
base_users.columns.push(col("name", ColumnType::Text));
let mut want_users = sqlite_users_table();
let mut name = col("name", ColumnType::Text);
name.nullable = true;
want_users.columns.push(name);
let baseline = vec![base_users, posts.clone()];
let desired = vec![want_users, posts];
let plan = diff_schema(&baseline, &parsed(desired.clone(), vec![]), DEFAULT_OPTS);
let ctx = SchemaContext::from_tables(&desired, &baseline);
emit_up_sql_with_context(&plan, &ctx).expect("a RESTRICT child does not block");
}
#[test]
fn a_bad_action_keeps_the_existing_column() {
let src = r#"
#[model(managed)]
pub struct Post {
#[id]
pub id: i64,
#[references(table = "users", on_delete = "casade")]
pub author_id: i64,
#[default]
pub created_at: chrono::NaiveDateTime,
}
"#;
let desired =
crate::schema::parse::parse_model_source(src, Backend::Postgres).expect("parse");
assert_eq!(desired.diagnostics.len(), 1, "{:?}", desired.diagnostics);
let valid = src.replace(", on_delete = \"casade\"", "");
let baseline = crate::schema::parse::parse_model_source(&valid, Backend::Postgres)
.expect("parse baseline")
.tables;
assert!(
baseline[0]
.indexes
.iter()
.any(|i| i.name == "idx_posts_author_id"),
"{baseline:?}"
);
let plan = diff_schema(&baseline, &desired, DEFAULT_OPTS);
assert!(
plan.is_empty(),
"the skipped column must not change: {plan:?}"
);
}
#[test]
fn sqlite_rebuild_of_a_self_cascade_table_is_refused() {
let mut base = sqlite_users_table();
let mut parent = col("parent_id", ColumnType::Int64);
parent.nullable = true;
parent.references =
Some(ForeignKey::new("users", "id").with_on_delete(Some(ForeignKeyAction::Cascade)));
base.columns.push(parent);
base.columns.push(col("name", ColumnType::Text));
let mut want = base.clone();
want.columns[2].nullable = true;
let baseline = vec![base];
let desired = vec![want];
let plan = diff_schema(&baseline, &parsed(desired.clone(), vec![]), DEFAULT_OPTS);
let ctx = SchemaContext::from_tables(&desired, &baseline);
let err = emit_up_sql_with_context(&plan, &ctx).expect_err("a self cascade must refuse");
assert!(err.to_string().contains("users.parent_id"), "{err}");
}
}