use crate::catalog::Catalog;
use crate::catalog::id::DbObjectId;
use crate::catalog::utils::is_system_schema;
use crate::diff::operations::{MigrationStep, OperationKind};
use crate::diff::{grants, namespace};
use std::collections::{BTreeMap, BTreeSet};
use tracing::warn;
pub fn order_planned(
steps: Vec<MigrationStep>,
old_catalog: &Catalog,
new_catalog: &Catalog,
module_of: &mut dyn FnMut(&MigrationStep) -> anyhow::Result<Option<String>>,
) -> anyhow::Result<Vec<PlannedStep>> {
let steps = grants::coalesce_column_grants(steps);
let planned = annotate(steps, old_catalog, new_catalog, module_of)?;
affinity_order(planned)
}
fn resolve_for_ordering(dep: &DbObjectId) -> DbObjectId {
match dep {
DbObjectId::Column { schema, table, .. } => DbObjectId::Table {
schema: schema.clone(),
name: table.clone(),
},
other => other.clone(),
}
}
#[allow(clippy::type_complexity)]
fn collect_edges(
steps: &[MigrationStep],
old_catalog: &Catalog,
new_catalog: &Catalog,
) -> (Vec<(usize, usize)>, Vec<(DbObjectId, DbObjectId)>) {
let mut id_to_indices: BTreeMap<DbObjectId, Vec<usize>> = BTreeMap::new();
for (i, step) in steps.iter().enumerate() {
id_to_indices.entry(step.id()).or_default().push(i);
}
let mut edges: Vec<(usize, usize)> = Vec::new();
let mut missing_deps: Vec<(DbObjectId, DbObjectId)> = Vec::new();
for (i, step) in steps.iter().enumerate() {
let is_drop = step.operation_kind() == OperationKind::Drop;
if let DbObjectId::Comment { object_id } = &step.id() {
if let Some(indices) = id_to_indices.get(object_id.as_ref()) {
for &dep_i in indices {
edges.push((dep_i, i));
}
}
if let DbObjectId::Constraint { schema, table, .. } = object_id.as_ref() {
let table_id = DbObjectId::Table {
schema: schema.clone(),
name: table.clone(),
};
if let Some(indices) = id_to_indices.get(&table_id) {
for &dep_i in indices {
edges.push((dep_i, i));
}
}
}
continue;
}
let catalog_deps = if is_drop {
old_catalog.forward_deps.get(&step.id())
} else {
new_catalog.forward_deps.get(&step.id())
};
if let Some(deps) = catalog_deps {
for dep in deps {
let resolved_dep = resolve_for_ordering(dep);
if let Some(indices) = id_to_indices.get(&resolved_dep) {
for &dep_i in indices {
if !is_drop && steps[dep_i].is_relationship() {
continue;
}
if is_drop {
edges.push((i, dep_i));
} else {
edges.push((dep_i, i));
}
}
} else {
let catalog = if is_drop { old_catalog } else { new_catalog };
if !catalog.contains_id(&resolved_dep) {
missing_deps.push((step.id(), dep.clone()));
}
}
}
} else {
let step_deps = step.dependencies();
for dep in &step_deps {
let resolved_dep = resolve_for_ordering(dep);
if let Some(indices) = id_to_indices.get(&resolved_dep) {
for &dep_i in indices {
if steps[dep_i].is_relationship() {
continue;
}
edges.push((dep_i, i));
}
} else {
if !new_catalog.contains_id(&resolved_dep) {
missing_deps.push((step.id(), dep.clone()));
}
}
}
}
}
let mut drop_indices: BTreeMap<DbObjectId, Vec<usize>> = BTreeMap::new();
let mut create_indices: BTreeMap<DbObjectId, Vec<usize>> = BTreeMap::new();
let mut other_indices: BTreeMap<DbObjectId, Vec<usize>> = BTreeMap::new();
for (i, step) in steps.iter().enumerate() {
let id = step.id();
match step.operation_kind() {
OperationKind::Drop => drop_indices.entry(id).or_default().push(i),
OperationKind::Create => create_indices.entry(id).or_default().push(i),
OperationKind::Alter => other_indices.entry(id).or_default().push(i),
}
}
for (id, drops) in &drop_indices {
if let Some(creates) = create_indices.get(id) {
for &drop_i in drops {
for &create_i in creates {
edges.push((drop_i, create_i));
}
}
}
}
{
let mut slot_drops: BTreeMap<namespace::NamespaceSlot, Vec<usize>> = BTreeMap::new();
let mut slot_creates: BTreeMap<namespace::NamespaceSlot, Vec<usize>> = BTreeMap::new();
for (i, step) in steps.iter().enumerate() {
let slots = namespace::namespace_slots(&step.id());
match step.operation_kind() {
OperationKind::Drop => {
for slot in slots {
slot_drops.entry(slot).or_default().push(i);
}
}
OperationKind::Create => {
for slot in slots {
slot_creates.entry(slot).or_default().push(i);
}
}
OperationKind::Alter => {}
}
}
for (slot, drops) in &slot_drops {
if let Some(creates) = slot_creates.get(slot) {
for &drop_i in drops {
for &create_i in creates {
if drop_i != create_i {
edges.push((drop_i, create_i));
}
}
}
}
}
}
for (id, creates) in &create_indices {
if let Some(others) = other_indices.get(id) {
for &create_i in creates {
for &other_i in others {
edges.push((create_i, other_i));
}
}
}
}
for indices in other_indices.values() {
for window in indices.windows(2) {
edges.push((window[0], window[1]));
}
}
{
let mut func_drops: BTreeMap<(String, String), Vec<usize>> = BTreeMap::new();
let mut func_creates: BTreeMap<(String, String), Vec<usize>> = BTreeMap::new();
for (i, step) in steps.iter().enumerate() {
let routine_name = match &step.id() {
DbObjectId::Function { schema, name, .. }
| DbObjectId::Procedure { schema, name, .. } => {
Some((schema.clone(), name.clone()))
}
_ => None,
};
if let Some(key) = routine_name {
match step.operation_kind() {
OperationKind::Drop => func_drops.entry(key).or_default().push(i),
OperationKind::Create => func_creates.entry(key).or_default().push(i),
_ => {}
}
}
}
for (key, drops) in &func_drops {
if let Some(creates) = func_creates.get(key) {
for &drop_i in drops {
for &create_i in creates {
edges.push((drop_i, create_i));
}
}
}
}
}
let extension_create_indices: Vec<usize> = steps
.iter()
.enumerate()
.filter_map(|(i, step)| {
if matches!(step, MigrationStep::Extension(_))
&& step.operation_kind() == OperationKind::Create
{
Some(i)
} else {
None
}
})
.collect();
let non_extension_create_indices: Vec<usize> = steps
.iter()
.enumerate()
.filter_map(|(i, step)| {
if !matches!(step, MigrationStep::Extension(_) | MigrationStep::Schema(_))
&& step.operation_kind() == OperationKind::Create
{
Some(i)
} else {
None
}
})
.collect();
for &ext_i in &extension_create_indices {
for &obj_i in &non_extension_create_indices {
edges.push((ext_i, obj_i));
}
}
(edges, missing_deps)
}
#[derive(Debug, Clone)]
pub struct PlannedStep {
pub step: MigrationStep,
pub module: Option<String>,
pub deps: Vec<usize>,
}
pub fn annotate(
steps: Vec<MigrationStep>,
old_catalog: &Catalog,
new_catalog: &Catalog,
module_of: &mut dyn FnMut(&MigrationStep) -> anyhow::Result<Option<String>>,
) -> anyhow::Result<Vec<PlannedStep>> {
let (edges, missing_deps) = collect_edges(&steps, old_catalog, new_catalog);
for (object_id, missing_dep) in &missing_deps {
if let Some(schema) = missing_dep.schema()
&& is_system_schema(schema)
{
continue;
}
warn!(
"{:?} depends on {:?} which is not in the catalog (may be filtered by config)",
object_id, missing_dep
);
}
let mut deps: Vec<BTreeSet<usize>> = vec![BTreeSet::new(); steps.len()];
for (before, after) in edges {
if before != after {
deps[after].insert(before);
}
}
let mut id_to_indices: BTreeMap<DbObjectId, Vec<usize>> = BTreeMap::new();
for (i, step) in steps.iter().enumerate() {
id_to_indices.entry(step.id()).or_default().push(i);
}
for (i, step) in steps.iter().enumerate() {
if let MigrationStep::Sequence(crate::diff::operations::SequenceOperation::AlterOwnership {
owned_by,
..
}) = step
&& owned_by != "NONE"
{
let parts: Vec<&str> = owned_by.split('.').collect();
if parts.len() == 3 {
let table_id = DbObjectId::Table {
schema: parts[0].to_string(),
name: parts[1].to_string(),
};
if let Some(indices) = id_to_indices.get(&table_id) {
for &dep_i in indices {
if dep_i != i {
deps[i].insert(dep_i);
}
}
}
}
}
}
steps
.into_iter()
.enumerate()
.map(|(i, step)| {
let module = module_of(&step)?;
Ok(PlannedStep {
step,
module,
deps: deps[i].iter().copied().collect(),
})
})
.collect()
}
pub fn affinity_order(planned: Vec<PlannedStep>) -> anyhow::Result<Vec<PlannedStep>> {
let n = planned.len();
let mut indegree = vec![0usize; n];
let mut dependents: Vec<Vec<usize>> = vec![Vec::new(); n];
for (i, node) in planned.iter().enumerate() {
indegree[i] = node.deps.len();
for &d in &node.deps {
dependents[d].push(i);
}
}
let mut ready: BTreeSet<usize> = (0..n).filter(|&i| indegree[i] == 0).collect();
let mut order: Vec<usize> = Vec::with_capacity(n);
let mut current: Option<&Option<String>> = None;
while let Some(next) = current
.and_then(|m| ready.iter().copied().find(|&i| planned[i].module == *m))
.or_else(|| ready.iter().next().copied())
{
ready.remove(&next);
current = Some(&planned[next].module);
order.push(next);
for &dep in &dependents[next] {
indegree[dep] -= 1;
if indegree[dep] == 0 {
ready.insert(dep);
}
}
}
if order.len() != n {
let emitted: BTreeSet<usize> = order.iter().copied().collect();
let stuck: Vec<String> = (0..n)
.filter(|i| !emitted.contains(i))
.take(6)
.map(|i| {
let unmet: Vec<String> = planned[i]
.deps
.iter()
.filter(|d| !emitted.contains(d))
.map(|&d| format!("{}", planned[d].step.id()))
.collect();
format!("{} <- [{}]", planned[i].step.id(), unmet.join(", "))
})
.collect();
anyhow::bail!(
"Dependency cycle detected in migration ordering; steps stuck waiting on each \
other:\n {}",
stuck.join("\n ")
);
}
let mut slots: Vec<Option<PlannedStep>> = planned.into_iter().map(Some).collect();
Ok(order
.into_iter()
.map(|i| slots[i].take().expect("permutation"))
.collect())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::diff::operations::{SchemaOperation, SequenceOperation};
fn schema_step(name: &str) -> MigrationStep {
MigrationStep::Schema(SchemaOperation::Create {
name: name.to_string(),
})
}
fn node(name: &str, module: Option<&str>, deps: &[usize]) -> PlannedStep {
PlannedStep {
step: schema_step(name),
module: module.map(str::to_string),
deps: deps.to_vec(),
}
}
fn modules_of(order: &[PlannedStep]) -> Vec<Option<&str>> {
order.iter().map(|n| n.module.as_deref()).collect()
}
#[test]
fn test_affinity_keeps_independent_modules_contiguous() {
let planned = vec![
node("a1", Some("a"), &[]),
node("b1", Some("b"), &[]),
node("a2", Some("a"), &[0]),
node("b2", Some("b"), &[1]),
];
let ordered = affinity_order(planned).unwrap();
assert_eq!(
modules_of(&ordered),
vec![Some("a"), Some("a"), Some("b"), Some("b")],
"modules must not interleave without a forcing dependency"
);
}
#[test]
fn test_affinity_interleaves_only_when_forced() {
let planned = vec![
node("core_create", Some("core"), &[2]), node("billing_drop", Some("billing"), &[]),
node("core_drop", Some("core"), &[1]),
node("billing_create", Some("billing"), &[0]),
];
let ordered = affinity_order(planned).unwrap();
assert_eq!(
modules_of(&ordered),
vec![Some("billing"), Some("core"), Some("core"), Some("billing")],
"forced coupling yields billing → core → billing"
);
}
#[test]
fn test_affinity_is_deterministic_and_base_first() {
let planned = vec![
node("base", None, &[]),
node("m1", Some("m"), &[]),
node("base2", None, &[]),
];
let ordered = affinity_order(planned).unwrap();
assert_eq!(modules_of(&ordered), vec![None, None, Some("m")]);
}
#[test]
fn test_affinity_detects_cycles() {
let planned = vec![node("x", None, &[1]), node("y", None, &[0])];
assert!(affinity_order(planned).is_err());
}
#[test]
fn test_annotate_adds_owned_by_edge() {
let table_step = MigrationStep::Table(crate::diff::operations::TableOperation::Create {
schema: "public".to_string(),
name: "users".to_string(),
columns: vec![],
primary_key: None,
});
let owned_by_step = MigrationStep::Sequence(SequenceOperation::AlterOwnership {
schema: "public".to_string(),
name: "users_id_seq".to_string(),
owned_by: "public.users.id".to_string(),
});
let steps = vec![owned_by_step, table_step];
let empty = Catalog::empty();
let planned = annotate(steps, &empty, &empty, &mut |_| Ok(None)).unwrap();
assert!(
planned[0].deps.contains(&1),
"OWNED BY must carry an explicit edge to its owning table"
);
let ordered = affinity_order(planned).unwrap();
assert!(
matches!(ordered[0].step, MigrationStep::Table(_)),
"table create must precede sequence ownership"
);
}
fn table_create(schema: &str, name: &str) -> MigrationStep {
MigrationStep::Table(crate::diff::operations::TableOperation::Create {
schema: schema.to_string(),
name: name.to_string(),
columns: vec![],
primary_key: None,
})
}
fn table_drop(schema: &str, name: &str) -> MigrationStep {
MigrationStep::Table(crate::diff::operations::TableOperation::Drop {
schema: schema.to_string(),
name: name.to_string(),
})
}
fn table_alter(schema: &str, name: &str) -> MigrationStep {
MigrationStep::Table(crate::diff::operations::TableOperation::Alter {
schema: schema.to_string(),
name: name.to_string(),
actions: vec![],
})
}
#[test]
fn test_collect_edges_same_id_drop_before_create() {
let steps = vec![
MigrationStep::Schema(SchemaOperation::Create {
name: "s".to_string(),
}),
MigrationStep::Schema(SchemaOperation::Drop {
name: "s".to_string(),
}),
];
let empty = Catalog::empty();
let (edges, _) = collect_edges(&steps, &empty, &empty);
assert!(
edges.contains(&(1, 0)),
"drop must precede create for the same id; edges={edges:?}"
);
}
#[test]
fn test_collect_edges_create_before_alter() {
let steps = vec![table_alter("public", "t"), table_create("public", "t")];
let empty = Catalog::empty();
let (edges, _) = collect_edges(&steps, &empty, &empty);
assert!(
edges.contains(&(1, 0)),
"create must precede alter for the same id; edges={edges:?}"
);
}
#[test]
fn test_collect_edges_same_id_alter_chain() {
let steps = vec![table_alter("public", "t"), table_alter("public", "t")];
let empty = Catalog::empty();
let (edges, _) = collect_edges(&steps, &empty, &empty);
assert!(
edges.contains(&(0, 1)),
"consecutive same-id alters keep emission order; edges={edges:?}"
);
}
#[test]
fn test_collect_edges_namespace_slot_drop_before_create() {
use crate::diff::operations::{ConstraintOperation, constraint::ConstraintIdentifier};
let steps = vec![
table_create("public", "foo"),
MigrationStep::Constraint(ConstraintOperation::Drop(ConstraintIdentifier {
schema: "public".to_string(),
table_name: "orders".to_string(),
name: "foo".to_string(),
})),
];
let empty = Catalog::empty();
let (edges, _) = collect_edges(&steps, &empty, &empty);
assert_ne!(steps[0].id(), steps[1].id());
assert!(
edges.contains(&(1, 0)),
"same-slot drop must precede the colliding create; edges={edges:?}"
);
}
#[test]
fn test_collect_edges_extensions_created_first() {
use crate::catalog::extension::Extension;
use crate::diff::operations::ExtensionOperation;
let steps = vec![
table_create("public", "t"),
MigrationStep::Extension(ExtensionOperation::Create {
extension: Extension {
name: "citext".to_string(),
schema: "public".to_string(),
version: "1.0".to_string(),
relocatable: false,
comment: None,
depends_on: vec![],
},
}),
];
let empty = Catalog::empty();
let (edges, _) = collect_edges(&steps, &empty, &empty);
assert!(
edges.contains(&(1, 0)),
"extension create must precede non-extension creates; edges={edges:?}"
);
}
#[test]
fn test_collect_edges_comment_follows_its_object() {
use crate::catalog::target::AttrTarget;
use crate::diff::operations::CommentOperation;
let steps = vec![
MigrationStep::Comment(CommentOperation::Set {
target: AttrTarget::object(DbObjectId::Schema {
name: "s".to_string(),
}),
comment: "hi".to_string(),
}),
MigrationStep::Schema(SchemaOperation::Create {
name: "s".to_string(),
}),
];
let empty = Catalog::empty();
let (edges, _) = collect_edges(&steps, &empty, &empty);
assert!(
edges.contains(&(1, 0)),
"comment must follow the step that creates its object; edges={edges:?}"
);
}
#[test]
fn test_collect_edges_forward_deps_create_direction() {
let a = DbObjectId::Table {
schema: "public".to_string(),
name: "a".to_string(),
};
let b = DbObjectId::Table {
schema: "public".to_string(),
name: "b".to_string(),
};
let steps = vec![table_create("public", "a"), table_create("public", "b")];
let mut new_catalog = Catalog::empty();
new_catalog.forward_deps.insert(a, vec![b]);
let empty = Catalog::empty();
let (edges, _) = collect_edges(&steps, &empty, &new_catalog);
assert!(
edges.contains(&(1, 0)),
"a create must follow its dependency b; edges={edges:?}"
);
}
#[test]
fn test_collect_edges_forward_deps_drop_direction_reversed() {
let a = DbObjectId::Table {
schema: "public".to_string(),
name: "a".to_string(),
};
let b = DbObjectId::Table {
schema: "public".to_string(),
name: "b".to_string(),
};
let steps = vec![table_drop("public", "a"), table_drop("public", "b")];
let mut old_catalog = Catalog::empty();
old_catalog.forward_deps.insert(a, vec![b]);
let empty = Catalog::empty();
let (edges, _) = collect_edges(&steps, &old_catalog, &empty);
assert!(
edges.contains(&(0, 1)),
"dependent a must be dropped before its dependency b; edges={edges:?}"
);
}
#[test]
fn test_collect_edges_step_declared_dependency_fallback() {
use crate::catalog::grant::{Grant, GranteeType};
use crate::catalog::target::AttrTarget;
use crate::diff::operations::GrantOperation;
let schema_id = DbObjectId::Schema {
name: "s".to_string(),
};
let grant = Grant {
grantee: GranteeType::Public,
target: AttrTarget::object(DbObjectId::Table {
schema: "s".to_string(),
name: "t".to_string(),
}),
privileges: vec!["SELECT".to_string()],
with_grant_option: false,
depends_on: vec![schema_id.clone()],
object_owner: "owner".to_string(),
is_default_acl: false,
};
let steps = vec![
MigrationStep::Grant(GrantOperation::Grant { grant }),
MigrationStep::Schema(SchemaOperation::Create {
name: "s".to_string(),
}),
];
let empty = Catalog::empty();
let (edges, _) = collect_edges(&steps, &empty, &empty);
assert!(
edges.contains(&(1, 0)),
"a step's declared dependency must precede it when the catalog is \
silent; edges={edges:?}"
);
}
#[test]
fn test_annotate_orders_routine_overload_drop_before_create() {
use crate::diff::operations::FunctionOperation;
let create_new = MigrationStep::Function(FunctionOperation::Create {
schema: "public".to_string(),
name: "calc".to_string(),
arguments: "integer, boolean".to_string(),
kind: "FUNCTION".to_string(),
parameters: "integer, boolean".to_string(),
returns: "integer".to_string(),
attributes: "".to_string(),
definition:
"CREATE FUNCTION public.calc(integer, boolean) RETURNS integer AS $$ SELECT 1 $$ \
LANGUAGE sql"
.to_string(),
});
let drop_old = MigrationStep::Function(FunctionOperation::Drop {
schema: "public".to_string(),
name: "calc".to_string(),
arguments: "integer".to_string(),
kind: "FUNCTION".to_string(),
parameter_types: "integer".to_string(),
});
assert_ne!(create_new.id(), drop_old.id());
let steps = vec![create_new, drop_old];
let empty = Catalog::empty();
let planned = annotate(steps, &empty, &empty, &mut |step: &MigrationStep| {
Ok(Some(
match step.operation_kind() {
OperationKind::Drop => "old_mod",
_ => "new_mod",
}
.to_string(),
))
})
.unwrap();
assert!(
planned[0].deps.contains(&1),
"new overload CREATE must depend on old overload DROP"
);
let ordered = affinity_order(planned).unwrap();
let drop_pos = ordered
.iter()
.position(|p| p.step.operation_kind() == OperationKind::Drop)
.unwrap();
let create_pos = ordered
.iter()
.position(|p| p.step.operation_kind() == OperationKind::Create)
.unwrap();
assert!(
drop_pos < create_pos,
"old overload must be dropped before the new overload is created"
);
}
}