use super::*;
use crate::diff::change::Change;
use crate::diff::changeset::ChangeSet;
use crate::diff::destructiveness::Destructiveness;
use crate::diff::sequence_op::{SequenceOp, SequenceOpEntry};
use crate::diff::table_op::{TableOp, TableOpEntry};
use crate::identifier::Identifier;
use crate::ir::column::Column;
use crate::ir::column_type::ColumnType;
use crate::ir::constraint::{
Constraint, ConstraintKind, Deferrable, FkMatchType, ForeignKey, ReferentialAction,
};
use crate::ir::index::{
Index, IndexColumn, IndexColumnExpr, IndexMethod, IndexParent, NullsOrder, SortOrder,
};
use crate::ir::schema::Schema;
use crate::ir::sequence::Sequence;
use crate::ir::table::Table;
use crate::plan::ordered::{DeferredFkAdd, OrderedChangeSet};
use crate::plan::ordering::order;
use crate::plan::policy::PlannerPolicy;
use crate::plan::raw_step::{StepKind, TransactionConstraint};
fn id(s: &str) -> Identifier {
Identifier::from_unquoted(s).unwrap()
}
fn qn(schema: &str, name: &str) -> QualifiedName {
QualifiedName::new(id(schema), id(name))
}
fn col(name: &str, ty: ColumnType, nullable: bool) -> Column {
Column {
name: id(name),
ty,
nullable,
default: None,
identity: None,
generated: None,
collation: None,
storage: None,
compression: None,
comment: None,
}
}
fn pk(name: &str, cols: &[&str]) -> Constraint {
Constraint {
qname: qn("app", name),
kind: ConstraintKind::PrimaryKey {
columns: cols.iter().map(|c| id(c)).collect(),
include: vec![],
},
deferrable: Deferrable::NotDeferrable,
comment: None,
}
}
fn make_index(name: &str, table: QualifiedName, unique: bool) -> Index {
Index {
qname: qn("app", name),
on: IndexParent::Table(table),
method: IndexMethod::BTree,
columns: vec![IndexColumn {
expr: IndexColumnExpr::Column(id("id")),
collation: None,
opclass: None,
sort_order: SortOrder::Asc,
nulls_order: NullsOrder::NullsLast,
}],
include: vec![],
unique,
nulls_not_distinct: false,
predicate: None,
tablespace: None,
comment: None,
storage: crate::ir::reloptions::IndexStorageOptions::default(),
}
}
fn rewrite_with_default(target: &Catalog, source: &Catalog, changes: ChangeSet) -> Vec<RawStep> {
let policy = PlannerPolicy::default();
let ordered = order(target, source, changes, &policy).unwrap();
rewrite(ordered, target, &policy)
}
fn rewrite_changeset_only(changes: ChangeSet) -> Vec<RawStep> {
rewrite(
OrderedChangeSet {
creates_and_adds: changes.entries,
modifies: vec![],
drops: vec![],
deferred_fks: vec![],
},
&Catalog::empty(),
&PlannerPolicy::default(),
)
}
#[test]
fn empty_ordered_set_yields_no_steps() {
let policy = PlannerPolicy::default();
let steps = rewrite(OrderedChangeSet::default(), &Catalog::empty(), &policy);
assert!(steps.is_empty());
}
#[test]
fn create_schema_emits_single_step() {
let mut cs = ChangeSet::new();
cs.push(
Change::CreateSchema(Schema::new(id("app"))),
Destructiveness::Safe,
);
let mut source = Catalog::empty();
source.schemas.push(Schema::new(id("app")));
let steps = rewrite_with_default(&Catalog::empty(), &source, cs);
assert_eq!(steps.len(), 1);
assert_eq!(steps[0].kind, StepKind::CreateSchema);
assert_eq!(steps[0].sql, "CREATE SCHEMA app;");
assert_eq!(steps[0].transactional, TransactionConstraint::InTransaction);
assert!(!steps[0].destructive);
}
#[test]
fn create_schema_with_comment_emits_two_steps() {
let s = Schema {
name: id("app"),
comment: Some("the app".into()),
owner: None,
grants: vec![],
};
let mut cs = ChangeSet::new();
cs.push(Change::CreateSchema(s), Destructiveness::Safe);
let steps = rewrite_changeset_only(cs);
assert_eq!(steps.len(), 2);
assert_eq!(steps[0].kind, StepKind::CreateSchema);
assert_eq!(steps[1].kind, StepKind::AlterSchemaComment);
assert_eq!(steps[1].sql, "COMMENT ON SCHEMA app IS 'the app';");
}
#[test]
fn drop_schema_marks_destructive() {
let mut cs = ChangeSet::new();
cs.push(
Change::DropSchema(id("legacy")),
Destructiveness::RequiresApproval {
reason: "drop schema".into(),
},
);
let steps = rewrite(
OrderedChangeSet {
drops: cs.entries,
..Default::default()
},
&Catalog::empty(),
&PlannerPolicy::default(),
);
assert_eq!(steps[0].kind, StepKind::DropSchema);
assert!(steps[0].destructive);
assert_eq!(steps[0].sql, "DROP SCHEMA legacy;");
}
#[test]
fn alter_schema_emits_comment_step() {
let mut cs = ChangeSet::new();
cs.push(
Change::AlterSchema {
name: id("app"),
comment: Some("v2".into()),
},
Destructiveness::Safe,
);
let steps = rewrite(
OrderedChangeSet {
modifies: cs.entries,
..Default::default()
},
&Catalog::empty(),
&PlannerPolicy::default(),
);
assert_eq!(steps.len(), 1);
assert_eq!(steps[0].kind, StepKind::AlterSchemaComment);
assert_eq!(steps[0].sql, "COMMENT ON SCHEMA app IS 'v2';");
}
#[test]
fn create_table_emits_full_create_with_columns_and_pk() {
let t = Table {
qname: qn("app", "users"),
columns: vec![
col("id", ColumnType::BigInt, false),
col("email", ColumnType::Text, true),
],
constraints: vec![pk("users_pkey", &["id"])],
partition_by: None,
partition_of: None,
comment: None,
owner: None,
grants: vec![],
rls_enabled: false,
rls_forced: false,
policies: vec![],
storage: crate::ir::reloptions::TableStorageOptions::default(),
access_method: None,
};
let mut cs = ChangeSet::new();
cs.push(Change::CreateTable(t), Destructiveness::Safe);
let steps = rewrite_changeset_only(cs);
assert_eq!(steps.len(), 1);
assert_eq!(steps[0].kind, StepKind::CreateTable);
assert!(steps[0].sql.starts_with("CREATE TABLE app.users ("));
assert!(steps[0].sql.contains("id bigint NOT NULL"));
assert!(steps[0].sql.contains("email text"));
assert!(
steps[0]
.sql
.contains("CONSTRAINT users_pkey PRIMARY KEY (id)")
);
}
#[test]
fn drop_table_marks_destructive() {
let mut cs = ChangeSet::new();
cs.push(
Change::DropTable {
qname: qn("app", "old"),
row_count_estimate: Some(100),
},
Destructiveness::RequiresApprovalAndDataLossWarning {
reason: "drops table".into(),
},
);
let steps = rewrite(
OrderedChangeSet {
drops: cs.entries,
..Default::default()
},
&Catalog::empty(),
&PlannerPolicy::default(),
);
assert_eq!(steps[0].kind, StepKind::DropTable);
assert!(steps[0].destructive);
assert_eq!(steps[0].sql, "DROP TABLE app.old;");
}
#[test]
fn create_index_emits_basic_create() {
let mut cs = ChangeSet::new();
cs.push(
Change::CreateIndex(make_index("users_idx", qn("app", "users"), false)),
Destructiveness::Safe,
);
let steps = rewrite_changeset_only(cs);
assert_eq!(steps.len(), 1);
assert_eq!(steps[0].kind, StepKind::CreateIndex);
assert!(
steps[0]
.sql
.starts_with("CREATE INDEX users_idx ON app.users USING btree (id)")
);
assert_eq!(steps[0].transactional, TransactionConstraint::InTransaction);
}
#[test]
fn drop_index_emits_plain_drop_in_default_path() {
let mut cs = ChangeSet::new();
cs.push(
Change::DropIndex(qn("app", "users_idx")),
Destructiveness::Safe,
);
let steps = rewrite(
OrderedChangeSet {
drops: cs.entries,
..Default::default()
},
&Catalog::empty(),
&PlannerPolicy::default(),
);
assert_eq!(steps[0].kind, StepKind::DropIndex);
assert_eq!(steps[0].sql, "DROP INDEX app.users_idx;");
}
#[test]
fn replace_index_emits_drop_then_create() {
let from = make_index("users_idx", qn("app", "users"), false);
let to = make_index("users_idx", qn("app", "users"), true);
let mut cs = ChangeSet::new();
cs.push(Change::ReplaceIndex { from, to }, Destructiveness::Safe);
let steps = rewrite(
OrderedChangeSet {
modifies: cs.entries,
..Default::default()
},
&Catalog::empty(),
&PlannerPolicy::default(),
);
assert_eq!(steps.len(), 2);
assert_eq!(steps[0].kind, StepKind::DropIndex);
assert_eq!(steps[1].kind, StepKind::CreateIndex);
assert!(steps[1].sql.contains("UNIQUE INDEX"));
}
#[test]
fn create_sequence_emits_full_create() {
let s = Sequence {
qname: qn("app", "id_seq"),
data_type: ColumnType::BigInt,
start: 1,
increment: 1,
min_value: None,
max_value: None,
cache: 1,
cycle: false,
owned_by: None,
comment: None,
owner: None,
grants: vec![],
};
let mut cs = ChangeSet::new();
cs.push(Change::CreateSequence(s), Destructiveness::Safe);
let steps = rewrite_changeset_only(cs);
assert_eq!(steps.len(), 1);
assert_eq!(steps[0].kind, StepKind::CreateSequence);
assert!(
steps[0]
.sql
.starts_with("CREATE SEQUENCE app.id_seq AS bigint")
);
assert!(steps[0].sql.contains("INCREMENT BY 1"));
assert!(steps[0].sql.contains("START WITH 1"));
assert!(steps[0].sql.contains("NO CYCLE"));
}
#[test]
fn alter_table_add_column_emits_step() {
let mut cs = ChangeSet::new();
cs.push(
Change::AlterTable {
qname: qn("app", "users"),
ops: vec![TableOpEntry {
op: TableOp::AddColumn(col("email", ColumnType::Text, true)),
destructiveness: Destructiveness::Safe,
}],
},
Destructiveness::Safe,
);
let steps = rewrite(
OrderedChangeSet {
modifies: cs.entries,
..Default::default()
},
&Catalog::empty(),
&PlannerPolicy::default(),
);
assert_eq!(steps.len(), 1);
assert_eq!(steps[0].kind, StepKind::AddColumn);
assert_eq!(steps[0].sql, "ALTER TABLE app.users ADD COLUMN email text;");
}
#[test]
fn alter_table_drop_column_marks_destructive() {
let mut cs = ChangeSet::new();
cs.push(
Change::AlterTable {
qname: qn("app", "users"),
ops: vec![TableOpEntry {
op: TableOp::DropColumn {
name: id("email"),
is_populated: true,
},
destructiveness: Destructiveness::RequiresApprovalAndDataLossWarning {
reason: "drop col".into(),
},
}],
},
Destructiveness::Safe,
);
let steps = rewrite(
OrderedChangeSet {
modifies: cs.entries,
..Default::default()
},
&Catalog::empty(),
&PlannerPolicy::default(),
);
assert!(steps[0].destructive);
assert_eq!(steps[0].sql, "ALTER TABLE app.users DROP COLUMN email;");
}
#[test]
fn alter_column_type_emits_using_clause_when_present() {
let mut cs = ChangeSet::new();
cs.push(
Change::AlterTable {
qname: qn("app", "users"),
ops: vec![TableOpEntry {
op: TableOp::AlterColumnType {
name: id("count"),
from: ColumnType::Integer,
to: ColumnType::BigInt,
using: Some(crate::ir::default_expr::NormalizedExpr::from_text(
"count::bigint",
)),
},
destructiveness: Destructiveness::RequiresApproval {
reason: "type change".into(),
},
}],
},
Destructiveness::Safe,
);
let steps = rewrite(
OrderedChangeSet {
modifies: cs.entries,
..Default::default()
},
&Catalog::empty(),
&PlannerPolicy::default(),
);
assert_eq!(
steps[0].sql,
"ALTER TABLE app.users ALTER COLUMN count TYPE bigint USING count::bigint;",
);
}
#[test]
fn set_column_nullable_distinguishes_directions() {
for (nullable, expected) in [
(true, "ALTER TABLE app.users ALTER COLUMN c DROP NOT NULL;"),
(false, "ALTER TABLE app.users ALTER COLUMN c SET NOT NULL;"),
] {
let mut cs = ChangeSet::new();
cs.push(
Change::AlterTable {
qname: qn("app", "users"),
ops: vec![TableOpEntry {
op: TableOp::SetColumnNullable {
name: id("c"),
nullable,
},
destructiveness: Destructiveness::Safe,
}],
},
Destructiveness::Safe,
);
let steps = rewrite(
OrderedChangeSet {
modifies: cs.entries,
..Default::default()
},
&Catalog::empty(),
&PlannerPolicy::default(),
);
assert_eq!(steps[0].sql, expected);
}
}
#[test]
fn add_constraint_emits_single_step_in_default_path() {
let mut cs = ChangeSet::new();
cs.push(
Change::AlterTable {
qname: qn("app", "users"),
ops: vec![TableOpEntry {
op: TableOp::AddConstraint(Constraint {
qname: qn("app", "users_email_uq"),
kind: ConstraintKind::Unique {
columns: vec![id("email")],
include: vec![],
nulls_distinct: true,
},
deferrable: Deferrable::NotDeferrable,
comment: None,
}),
destructiveness: Destructiveness::Safe,
}],
},
Destructiveness::Safe,
);
let steps = rewrite(
OrderedChangeSet {
modifies: cs.entries,
..Default::default()
},
&Catalog::empty(),
&PlannerPolicy::default(),
);
assert_eq!(steps.len(), 1);
assert_eq!(steps[0].kind, StepKind::AddConstraint);
assert!(steps[0].sql.contains("UNIQUE"));
}
#[test]
fn drop_constraint_emits_step() {
let mut cs = ChangeSet::new();
cs.push(
Change::AlterTable {
qname: qn("app", "users"),
ops: vec![TableOpEntry {
op: TableOp::DropConstraint {
name: id("users_email_uq"),
},
destructiveness: Destructiveness::Safe,
}],
},
Destructiveness::Safe,
);
let steps = rewrite(
OrderedChangeSet {
modifies: cs.entries,
..Default::default()
},
&Catalog::empty(),
&PlannerPolicy::default(),
);
assert_eq!(steps[0].kind, StepKind::DropConstraint);
assert_eq!(
steps[0].sql,
"ALTER TABLE app.users DROP CONSTRAINT users_email_uq;",
);
}
#[test]
fn alter_sequence_set_increment_emits_step() {
let mut cs = ChangeSet::new();
cs.push(
Change::AlterSequence {
qname: qn("app", "s1"),
ops: vec![SequenceOpEntry {
op: SequenceOp::SetIncrement(2),
destructiveness: Destructiveness::Safe,
}],
},
Destructiveness::Safe,
);
let steps = rewrite(
OrderedChangeSet {
modifies: cs.entries,
..Default::default()
},
&Catalog::empty(),
&PlannerPolicy::default(),
);
assert_eq!(steps[0].kind, StepKind::AlterSequence);
assert_eq!(steps[0].sql, "ALTER SEQUENCE app.s1 INCREMENT BY 2;");
}
#[test]
fn alter_sequence_set_owned_by_renders_qualified_owner() {
let mut cs = ChangeSet::new();
cs.push(
Change::AlterSequence {
qname: qn("app", "s1"),
ops: vec![SequenceOpEntry {
op: SequenceOp::SetOwnedBy(Some(crate::ir::sequence::SequenceOwner {
table: qn("app", "users"),
column: id("id"),
})),
destructiveness: Destructiveness::Safe,
}],
},
Destructiveness::Safe,
);
let steps = rewrite(
OrderedChangeSet {
modifies: cs.entries,
..Default::default()
},
&Catalog::empty(),
&PlannerPolicy::default(),
);
assert_eq!(steps[0].sql, "ALTER SEQUENCE app.s1 OWNED BY app.users.id;");
}
#[test]
fn drop_sequence_marks_destructive() {
let mut cs = ChangeSet::new();
cs.push(
Change::DropSequence(qn("app", "s1")),
Destructiveness::RequiresApproval {
reason: "drop seq".into(),
},
);
let steps = rewrite(
OrderedChangeSet {
drops: cs.entries,
..Default::default()
},
&Catalog::empty(),
&PlannerPolicy::default(),
);
assert!(steps[0].destructive);
assert_eq!(steps[0].sql, "DROP SEQUENCE app.s1;");
}
#[test]
fn deferred_fk_emits_alter_table_add_constraint() {
let fk = DeferredFkAdd {
table: qn("app", "a"),
constraint: Constraint {
qname: qn("app", "a_b_fk"),
kind: ConstraintKind::ForeignKey(ForeignKey {
columns: vec![id("ref_id")],
referenced_table: qn("app", "b"),
referenced_columns: vec![id("id")],
on_update: ReferentialAction::NoAction,
on_delete: ReferentialAction::NoAction,
match_type: FkMatchType::Simple,
}),
deferrable: Deferrable::NotDeferrable,
comment: None,
},
};
let steps = rewrite(
OrderedChangeSet {
deferred_fks: vec![fk],
..Default::default()
},
&Catalog::empty(),
&PlannerPolicy::default(),
);
assert_eq!(steps.len(), 1);
assert_eq!(steps[0].kind, StepKind::AddConstraint);
assert!(
steps[0]
.sql
.contains("ADD CONSTRAINT a_b_fk FOREIGN KEY (ref_id) REFERENCES app.b (id)")
);
}
#[test]
fn create_index_on_existing_table_rewrites_to_concurrent() {
let mut target = Catalog::empty();
target.tables.push(Table {
qname: qn("app", "users"),
columns: vec![col("id", ColumnType::BigInt, false)],
constraints: vec![],
partition_by: None,
partition_of: None,
comment: None,
owner: None,
grants: vec![],
rls_enabled: false,
rls_forced: false,
policies: vec![],
storage: crate::ir::reloptions::TableStorageOptions::default(),
access_method: None,
});
let idx = make_index("users_idx", qn("app", "users"), false);
let mut cs = ChangeSet::new();
cs.push(Change::CreateIndex(idx), Destructiveness::Safe);
let steps = rewrite(
OrderedChangeSet {
creates_and_adds: cs.entries,
..Default::default()
},
&target,
&PlannerPolicy::default(),
);
assert_eq!(steps.len(), 1);
assert_eq!(steps[0].kind, StepKind::CreateIndexConcurrent);
assert_eq!(
steps[0].transactional,
TransactionConstraint::OutsideTransaction,
);
assert!(steps[0].sql.contains("CONCURRENTLY"));
}
#[test]
fn create_index_on_new_table_stays_inline() {
let idx = make_index("users_idx", qn("app", "users"), false);
let mut cs = ChangeSet::new();
cs.push(Change::CreateIndex(idx), Destructiveness::Safe);
let steps = rewrite(
OrderedChangeSet {
creates_and_adds: cs.entries,
..Default::default()
},
&Catalog::empty(),
&PlannerPolicy::default(),
);
assert_eq!(steps[0].kind, StepKind::CreateIndex);
assert_eq!(steps[0].transactional, TransactionConstraint::InTransaction);
assert!(!steps[0].sql.contains("CONCURRENTLY"));
}
#[test]
fn unique_create_index_does_not_rewrite_to_concurrent() {
let mut target = Catalog::empty();
target.tables.push(Table {
qname: qn("app", "users"),
columns: vec![col("id", ColumnType::BigInt, false)],
constraints: vec![],
partition_by: None,
partition_of: None,
comment: None,
owner: None,
grants: vec![],
rls_enabled: false,
rls_forced: false,
policies: vec![],
storage: crate::ir::reloptions::TableStorageOptions::default(),
access_method: None,
});
let idx = make_index("users_email_idx", qn("app", "users"), true);
let mut cs = ChangeSet::new();
cs.push(Change::CreateIndex(idx), Destructiveness::Safe);
let steps = rewrite(
OrderedChangeSet {
creates_and_adds: cs.entries,
..Default::default()
},
&target,
&PlannerPolicy::default(),
);
assert_eq!(steps[0].kind, StepKind::CreateIndex);
assert!(steps[0].sql.contains("UNIQUE INDEX"));
assert!(!steps[0].sql.contains("CONCURRENTLY"));
}
#[test]
fn atomic_policy_disables_concurrent_index_rewrite() {
let mut target = Catalog::empty();
target.tables.push(Table {
qname: qn("app", "users"),
columns: vec![col("id", ColumnType::BigInt, false)],
constraints: vec![],
partition_by: None,
partition_of: None,
comment: None,
owner: None,
grants: vec![],
rls_enabled: false,
rls_forced: false,
policies: vec![],
storage: crate::ir::reloptions::TableStorageOptions::default(),
access_method: None,
});
let idx = make_index("users_idx", qn("app", "users"), false);
let mut cs = ChangeSet::new();
cs.push(Change::CreateIndex(idx), Destructiveness::Safe);
let policy = PlannerPolicy {
strategy: crate::plan::policy::Strategy::Atomic,
..PlannerPolicy::default()
};
let steps = rewrite(
OrderedChangeSet {
creates_and_adds: cs.entries,
..Default::default()
},
&target,
&policy,
);
assert_eq!(steps[0].kind, StepKind::CreateIndex);
assert!(!steps[0].sql.contains("CONCURRENTLY"));
}
#[test]
fn drop_index_on_existing_non_unique_rewrites_to_concurrent() {
let mut target = Catalog::empty();
target
.indexes
.push(make_index("users_idx", qn("app", "users"), false));
let mut cs = ChangeSet::new();
cs.push(
Change::DropIndex(qn("app", "users_idx")),
Destructiveness::Safe,
);
let steps = rewrite(
OrderedChangeSet {
drops: cs.entries,
..Default::default()
},
&target,
&PlannerPolicy::default(),
);
assert_eq!(steps[0].kind, StepKind::DropIndexConcurrent);
assert_eq!(
steps[0].transactional,
TransactionConstraint::OutsideTransaction
);
}
#[test]
fn drop_unique_index_stays_inline() {
let mut target = Catalog::empty();
target
.indexes
.push(make_index("users_idx", qn("app", "users"), true));
let mut cs = ChangeSet::new();
cs.push(
Change::DropIndex(qn("app", "users_idx")),
Destructiveness::Safe,
);
let steps = rewrite(
OrderedChangeSet {
drops: cs.entries,
..Default::default()
},
&target,
&PlannerPolicy::default(),
);
assert_eq!(steps[0].kind, StepKind::DropIndex);
assert_eq!(steps[0].transactional, TransactionConstraint::InTransaction);
}
#[test]
fn drop_index_unknown_in_target_stays_inline() {
let mut cs = ChangeSet::new();
cs.push(
Change::DropIndex(qn("app", "users_idx")),
Destructiveness::Safe,
);
let steps = rewrite(
OrderedChangeSet {
drops: cs.entries,
..Default::default()
},
&Catalog::empty(),
&PlannerPolicy::default(),
);
assert_eq!(steps[0].kind, StepKind::DropIndex);
}
fn fk(name: &str, ref_table: QualifiedName) -> Constraint {
Constraint {
qname: qn("app", name),
kind: ConstraintKind::ForeignKey(ForeignKey {
columns: vec![id("ref_id")],
referenced_table: ref_table,
referenced_columns: vec![id("id")],
on_update: ReferentialAction::NoAction,
on_delete: ReferentialAction::NoAction,
match_type: FkMatchType::Simple,
}),
deferrable: Deferrable::NotDeferrable,
comment: None,
}
}
#[test]
fn add_fk_on_existing_table_emits_two_steps() {
let mut target = Catalog::empty();
target.tables.push(Table {
qname: qn("app", "users"),
columns: vec![
col("id", ColumnType::BigInt, false),
col("ref_id", ColumnType::BigInt, false),
],
constraints: vec![],
partition_by: None,
partition_of: None,
comment: None,
owner: None,
grants: vec![],
rls_enabled: false,
rls_forced: false,
policies: vec![],
storage: crate::ir::reloptions::TableStorageOptions::default(),
access_method: None,
});
target.tables.push(Table {
qname: qn("app", "orgs"),
columns: vec![col("id", ColumnType::BigInt, false)],
constraints: vec![],
partition_by: None,
partition_of: None,
comment: None,
owner: None,
grants: vec![],
rls_enabled: false,
rls_forced: false,
policies: vec![],
storage: crate::ir::reloptions::TableStorageOptions::default(),
access_method: None,
});
let mut cs = ChangeSet::new();
cs.push(
Change::AlterTable {
qname: qn("app", "users"),
ops: vec![TableOpEntry {
op: TableOp::AddConstraint(fk("users_orgs_fk", qn("app", "orgs"))),
destructiveness: Destructiveness::Safe,
}],
},
Destructiveness::Safe,
);
let steps = rewrite(
OrderedChangeSet {
modifies: cs.entries,
..Default::default()
},
&target,
&PlannerPolicy::default(),
);
assert_eq!(steps.len(), 2);
assert_eq!(steps[0].kind, StepKind::AddConstraintNotValid);
assert!(steps[0].sql.contains("NOT VALID"));
assert_eq!(steps[1].kind, StepKind::ValidateConstraint);
assert_eq!(
steps[1].sql,
"ALTER TABLE app.users VALIDATE CONSTRAINT users_orgs_fk;",
);
}
#[test]
fn add_fk_on_new_table_via_alter_stays_inline_when_target_missing() {
let mut cs = ChangeSet::new();
cs.push(
Change::AlterTable {
qname: qn("app", "users"),
ops: vec![TableOpEntry {
op: TableOp::AddConstraint(fk("users_orgs_fk", qn("app", "orgs"))),
destructiveness: Destructiveness::Safe,
}],
},
Destructiveness::Safe,
);
let steps = rewrite(
OrderedChangeSet {
modifies: cs.entries,
..Default::default()
},
&Catalog::empty(),
&PlannerPolicy::default(),
);
assert_eq!(steps.len(), 1);
assert_eq!(steps[0].kind, StepKind::AddConstraint);
}
#[test]
fn add_fk_with_atomic_policy_stays_inline() {
let mut target = Catalog::empty();
target.tables.push(Table {
qname: qn("app", "users"),
columns: vec![
col("id", ColumnType::BigInt, false),
col("ref_id", ColumnType::BigInt, false),
],
constraints: vec![],
partition_by: None,
partition_of: None,
comment: None,
owner: None,
grants: vec![],
rls_enabled: false,
rls_forced: false,
policies: vec![],
storage: crate::ir::reloptions::TableStorageOptions::default(),
access_method: None,
});
let mut cs = ChangeSet::new();
cs.push(
Change::AlterTable {
qname: qn("app", "users"),
ops: vec![TableOpEntry {
op: TableOp::AddConstraint(fk("users_orgs_fk", qn("app", "orgs"))),
destructiveness: Destructiveness::Safe,
}],
},
Destructiveness::Safe,
);
let policy = PlannerPolicy {
strategy: crate::plan::policy::Strategy::Atomic,
..PlannerPolicy::default()
};
let steps = rewrite(
OrderedChangeSet {
modifies: cs.entries,
..Default::default()
},
&target,
&policy,
);
assert_eq!(steps.len(), 1);
assert_eq!(steps[0].kind, StepKind::AddConstraint);
}
#[test]
fn add_unique_constraint_on_existing_table_does_not_trigger_fk_rewrite() {
let mut target = Catalog::empty();
target.tables.push(Table {
qname: qn("app", "users"),
columns: vec![
col("id", ColumnType::BigInt, false),
col("email", ColumnType::Text, true),
],
constraints: vec![],
partition_by: None,
partition_of: None,
comment: None,
owner: None,
grants: vec![],
rls_enabled: false,
rls_forced: false,
policies: vec![],
storage: crate::ir::reloptions::TableStorageOptions::default(),
access_method: None,
});
let mut cs = ChangeSet::new();
cs.push(
Change::AlterTable {
qname: qn("app", "users"),
ops: vec![TableOpEntry {
op: TableOp::AddConstraint(Constraint {
qname: qn("app", "users_email_uq"),
kind: ConstraintKind::Unique {
columns: vec![id("email")],
include: vec![],
nulls_distinct: true,
},
deferrable: Deferrable::NotDeferrable,
comment: None,
}),
destructiveness: Destructiveness::Safe,
}],
},
Destructiveness::Safe,
);
let steps = rewrite(
OrderedChangeSet {
modifies: cs.entries,
..Default::default()
},
&target,
&PlannerPolicy::default(),
);
assert_eq!(steps.len(), 1);
assert_eq!(steps[0].kind, StepKind::AddConstraint);
}
fn check(name: &str, expr: &str) -> Constraint {
Constraint {
qname: qn("app", name),
kind: ConstraintKind::Check {
expression: crate::ir::default_expr::NormalizedExpr::from_text(expr),
no_inherit: false,
},
deferrable: Deferrable::NotDeferrable,
comment: None,
}
}
#[test]
fn add_check_on_existing_table_emits_two_steps() {
let mut target = Catalog::empty();
target.tables.push(Table {
qname: qn("app", "users"),
columns: vec![col("age", ColumnType::Integer, true)],
constraints: vec![],
partition_by: None,
partition_of: None,
comment: None,
owner: None,
grants: vec![],
rls_enabled: false,
rls_forced: false,
policies: vec![],
storage: crate::ir::reloptions::TableStorageOptions::default(),
access_method: None,
});
let mut cs = ChangeSet::new();
cs.push(
Change::AlterTable {
qname: qn("app", "users"),
ops: vec![TableOpEntry {
op: TableOp::AddConstraint(check("users_age_chk", "age >= 0")),
destructiveness: Destructiveness::Safe,
}],
},
Destructiveness::Safe,
);
let steps = rewrite(
OrderedChangeSet {
modifies: cs.entries,
..Default::default()
},
&target,
&PlannerPolicy::default(),
);
assert_eq!(steps.len(), 2);
assert_eq!(steps[0].kind, StepKind::AddConstraintNotValid);
assert!(steps[0].sql.contains("CHECK (age >= 0)"));
assert!(steps[0].sql.contains("NOT VALID"));
assert_eq!(steps[1].kind, StepKind::ValidateConstraint);
}
#[test]
fn add_check_on_new_table_via_alter_stays_inline_when_target_missing() {
let mut cs = ChangeSet::new();
cs.push(
Change::AlterTable {
qname: qn("app", "users"),
ops: vec![TableOpEntry {
op: TableOp::AddConstraint(check("users_age_chk", "age >= 0")),
destructiveness: Destructiveness::Safe,
}],
},
Destructiveness::Safe,
);
let steps = rewrite(
OrderedChangeSet {
modifies: cs.entries,
..Default::default()
},
&Catalog::empty(),
&PlannerPolicy::default(),
);
assert_eq!(steps.len(), 1);
assert_eq!(steps[0].kind, StepKind::AddConstraint);
}
#[test]
fn add_check_with_atomic_policy_stays_inline() {
let mut target = Catalog::empty();
target.tables.push(Table {
qname: qn("app", "users"),
columns: vec![col("age", ColumnType::Integer, true)],
constraints: vec![],
partition_by: None,
partition_of: None,
comment: None,
owner: None,
grants: vec![],
rls_enabled: false,
rls_forced: false,
policies: vec![],
storage: crate::ir::reloptions::TableStorageOptions::default(),
access_method: None,
});
let mut cs = ChangeSet::new();
cs.push(
Change::AlterTable {
qname: qn("app", "users"),
ops: vec![TableOpEntry {
op: TableOp::AddConstraint(check("users_age_chk", "age >= 0")),
destructiveness: Destructiveness::Safe,
}],
},
Destructiveness::Safe,
);
let policy = PlannerPolicy {
strategy: crate::plan::policy::Strategy::Atomic,
..PlannerPolicy::default()
};
let steps = rewrite(
OrderedChangeSet {
modifies: cs.entries,
..Default::default()
},
&target,
&policy,
);
assert_eq!(steps.len(), 1);
assert_eq!(steps[0].kind, StepKind::AddConstraint);
}
fn target_with_users_and_email() -> Catalog {
let mut target = Catalog::empty();
target.tables.push(Table {
qname: qn("app", "users"),
columns: vec![
col("id", ColumnType::BigInt, false),
col("email", ColumnType::Text, true),
],
constraints: vec![],
partition_by: None,
partition_of: None,
comment: None,
owner: None,
grants: vec![],
rls_enabled: false,
rls_forced: false,
policies: vec![],
storage: crate::ir::reloptions::TableStorageOptions::default(),
access_method: None,
});
target
}
#[test]
fn set_not_null_on_existing_column_emits_four_steps() {
let target = target_with_users_and_email();
let mut cs = ChangeSet::new();
cs.push(
Change::AlterTable {
qname: qn("app", "users"),
ops: vec![TableOpEntry {
op: TableOp::SetColumnNullable {
name: id("email"),
nullable: false,
},
destructiveness: Destructiveness::RequiresApproval {
reason: "set not null".into(),
},
}],
},
Destructiveness::Safe,
);
let steps = rewrite(
OrderedChangeSet {
modifies: cs.entries,
..Default::default()
},
&target,
&PlannerPolicy::default(),
);
assert_eq!(steps.len(), 4);
assert_eq!(steps[0].kind, StepKind::AddCheckForNotNull);
assert!(steps[0].sql.contains("__pgevolve_chk_email"));
assert!(steps[0].sql.contains("CHECK (email IS NOT NULL)"));
assert!(steps[0].sql.contains("NOT VALID"));
assert_eq!(steps[1].kind, StepKind::ValidateConstraint);
assert!(steps[1].sql.contains("__pgevolve_chk_email"));
assert_eq!(steps[2].kind, StepKind::SetColumnNullable);
assert_eq!(
steps[2].sql,
"ALTER TABLE app.users ALTER COLUMN email SET NOT NULL;",
);
assert_eq!(steps[3].kind, StepKind::DropConstraint);
assert!(steps[3].sql.contains("__pgevolve_chk_email"));
}
#[test]
fn set_not_null_on_unknown_column_stays_single_step() {
let mut cs = ChangeSet::new();
cs.push(
Change::AlterTable {
qname: qn("app", "users"),
ops: vec![TableOpEntry {
op: TableOp::SetColumnNullable {
name: id("email"),
nullable: false,
},
destructiveness: Destructiveness::Safe,
}],
},
Destructiveness::Safe,
);
let steps = rewrite(
OrderedChangeSet {
modifies: cs.entries,
..Default::default()
},
&Catalog::empty(),
&PlannerPolicy::default(),
);
assert_eq!(steps.len(), 1);
assert_eq!(steps[0].kind, StepKind::SetColumnNullable);
}
#[test]
fn set_not_null_with_atomic_policy_stays_single_step() {
let target = target_with_users_and_email();
let mut cs = ChangeSet::new();
cs.push(
Change::AlterTable {
qname: qn("app", "users"),
ops: vec![TableOpEntry {
op: TableOp::SetColumnNullable {
name: id("email"),
nullable: false,
},
destructiveness: Destructiveness::Safe,
}],
},
Destructiveness::Safe,
);
let policy = PlannerPolicy {
strategy: crate::plan::policy::Strategy::Atomic,
..PlannerPolicy::default()
};
let steps = rewrite(
OrderedChangeSet {
modifies: cs.entries,
..Default::default()
},
&target,
&policy,
);
assert_eq!(steps.len(), 1);
assert_eq!(steps[0].kind, StepKind::SetColumnNullable);
}
#[test]
fn drop_not_null_is_always_single_step() {
let target = target_with_users_and_email();
let mut cs = ChangeSet::new();
cs.push(
Change::AlterTable {
qname: qn("app", "users"),
ops: vec![TableOpEntry {
op: TableOp::SetColumnNullable {
name: id("email"),
nullable: true,
},
destructiveness: Destructiveness::Safe,
}],
},
Destructiveness::Safe,
);
let steps = rewrite(
OrderedChangeSet {
modifies: cs.entries,
..Default::default()
},
&target,
&PlannerPolicy::default(),
);
assert_eq!(steps.len(), 1);
assert_eq!(steps[0].kind, StepKind::SetColumnNullable);
assert!(steps[0].sql.contains("DROP NOT NULL"));
}
#[test]
fn rewrite_preserves_bucket_order_creates_modifies_drops() {
let mut source = Catalog::empty();
source.schemas.push(Schema::new(id("app")));
source.tables.push(Table {
qname: qn("app", "users"),
columns: vec![col("id", ColumnType::BigInt, false)],
constraints: vec![],
partition_by: None,
partition_of: None,
comment: None,
owner: None,
grants: vec![],
rls_enabled: false,
rls_forced: false,
policies: vec![],
storage: crate::ir::reloptions::TableStorageOptions::default(),
access_method: None,
});
let mut cs = ChangeSet::new();
cs.push(
Change::CreateSchema(Schema::new(id("app"))),
Destructiveness::Safe,
);
cs.push(
Change::CreateTable(source.tables[0].clone()),
Destructiveness::Safe,
);
cs.push(
Change::AlterTable {
qname: qn("app", "users"),
ops: vec![],
},
Destructiveness::Safe,
);
cs.push(
Change::DropSchema(id("legacy")),
Destructiveness::RequiresApproval { reason: "x".into() },
);
let mut target = Catalog::empty();
target.schemas.push(Schema::new(id("legacy")));
let steps = rewrite_with_default(&target, &source, cs);
let kinds: Vec<StepKind> = steps.iter().map(|s| s.kind).collect();
assert_eq!(
kinds,
vec![
StepKind::CreateSchema,
StepKind::CreateTable,
StepKind::DropSchema
]
);
}
#[test]
fn emits_attach_partition_step() {
use crate::diff::change::TableChange;
use crate::ir::partition::PartitionBounds;
let mut cs = ChangeSet::new();
cs.push(
Change::Table(TableChange::AttachPartition {
parent: qn("app", "orders"),
child: qn("app", "orders_2024"),
bounds: PartitionBounds::Default,
}),
Destructiveness::Safe,
);
let steps = rewrite_changeset_only(cs);
assert_eq!(steps.len(), 1);
assert_eq!(steps[0].kind, StepKind::AttachPartition);
assert!(
steps[0].sql.contains("ATTACH PARTITION"),
"expected ATTACH PARTITION in sql, got: {}",
steps[0].sql
);
assert!(!steps[0].destructive);
assert_eq!(steps[0].targets, vec![qn("app", "orders_2024")]);
assert_eq!(steps[0].transactional, TransactionConstraint::InTransaction);
}
#[test]
fn emits_detach_partition_step() {
use crate::diff::change::TableChange;
let mut cs = ChangeSet::new();
cs.push(
Change::Table(TableChange::DetachPartition {
parent: qn("app", "orders"),
child: qn("app", "orders_2024"),
}),
Destructiveness::Safe,
);
let steps = rewrite_changeset_only(cs);
assert_eq!(steps.len(), 1);
assert_eq!(steps[0].kind, StepKind::DetachPartition);
assert!(
steps[0].sql.contains("DETACH PARTITION"),
"expected DETACH PARTITION in sql, got: {}",
steps[0].sql
);
assert!(!steps[0].destructive);
assert_eq!(steps[0].targets, vec![qn("app", "orders_2024")]);
assert_eq!(steps[0].transactional, TransactionConstraint::InTransaction);
}
fn minimal_subscription(create_slot: Option<bool>) -> crate::ir::subscription::Subscription {
use crate::ir::subscription::{Subscription, SubscriptionOptions};
Subscription {
name: id("mysub"),
connection: "host=db.example.com dbname=app".to_string(),
publications: vec![id("mypub")],
options: SubscriptionOptions {
create_slot,
..SubscriptionOptions::default()
},
owner: None,
comment: None,
}
}
fn create_subscription_changeset(sub: crate::ir::subscription::Subscription) -> Vec<RawStep> {
let mut cs = crate::diff::changeset::ChangeSet::new();
cs.push(
Change::Subscription(crate::diff::change::SubscriptionChange::Create(sub)),
crate::diff::destructiveness::Destructiveness::Safe,
);
rewrite_changeset_only(cs)
}
#[test]
fn create_subscription_create_slot_none_is_outside_transaction() {
let sub = minimal_subscription(None);
let steps = create_subscription_changeset(sub);
assert_eq!(steps[0].kind, StepKind::CreateSubscription);
assert_eq!(
steps[0].transactional,
TransactionConstraint::OutsideTransaction,
"create_slot=None means PG default true → must run outside transaction"
);
}
#[test]
fn create_subscription_create_slot_true_is_outside_transaction() {
let sub = minimal_subscription(Some(true));
let steps = create_subscription_changeset(sub);
assert_eq!(steps[0].kind, StepKind::CreateSubscription);
assert_eq!(
steps[0].transactional,
TransactionConstraint::OutsideTransaction,
"create_slot=Some(true) → must run outside transaction"
);
}
#[test]
fn create_subscription_create_slot_false_is_in_transaction() {
let sub = minimal_subscription(Some(false));
let steps = create_subscription_changeset(sub);
assert_eq!(steps[0].kind, StepKind::CreateSubscription);
assert_eq!(
steps[0].transactional,
TransactionConstraint::InTransaction,
"create_slot=Some(false) → safe to run inside transaction"
);
}
#[test]
fn drop_subscription_is_always_outside_transaction() {
let mut cs = crate::diff::changeset::ChangeSet::new();
cs.push(
Change::Subscription(crate::diff::change::SubscriptionChange::Drop { name: id("mysub") }),
crate::diff::destructiveness::Destructiveness::RequiresApproval {
reason: "drop subscription".into(),
},
);
let steps = rewrite(
OrderedChangeSet {
drops: cs.entries,
..Default::default()
},
&Catalog::empty(),
&PlannerPolicy::default(),
);
assert_eq!(steps.len(), 1);
assert_eq!(steps[0].kind, StepKind::DropSubscription);
assert_eq!(
steps[0].transactional,
TransactionConstraint::OutsideTransaction,
"DROP SUBSCRIPTION must always run outside transaction (PG error 25001 when slot is attached)"
);
assert_eq!(steps[0].sql, "DROP SUBSCRIPTION mysub;");
}