use std::collections::{HashMap, HashSet};
use thiserror::Error;
use crate::migrations::Migration;
#[derive(Debug, Error)]
pub enum GraphError {
#[error("duplicate migration id: {0}")]
DuplicateId(String),
#[error("cycle detected in migration graph")]
CycleDetected,
#[error("multiple heads detected — run make --merge to resolve")]
Conflict,
#[error("unknown dependency '{dep}' referenced by '{migration}'")]
UnknownDependency { migration: String, dep: String },
#[error("no migrations in graph")]
Empty,
#[error(
"invalid migration id '{0}': only lowercase letters, digits, and underscores are allowed (namespaced ids like 'auth/0001_init' are set automatically by embedded children)"
)]
InvalidId(String),
#[error("unknown migration id or prefix '{0}'")]
UnknownId(String),
#[error("migration id prefix '{prefix}' is ambiguous: {matches}")]
AmbiguousId { prefix: String, matches: String },
}
#[derive(Debug, Clone)]
pub struct MigrationNode {
pub migration: Migration,
}
#[derive(Debug, Default)]
pub struct MigrationGraph {
nodes: HashMap<String, MigrationNode>,
}
impl MigrationGraph {
pub fn new() -> Self {
Self::default()
}
pub fn add(&mut self, migration: Migration) -> Result<(), GraphError> {
let id = migration.id.clone();
if self.nodes.contains_key(&id) {
return Err(GraphError::DuplicateId(id));
}
self.nodes.insert(id, MigrationNode { migration });
Ok(())
}
pub fn validate_id(id: &str) -> Result<(), GraphError> {
if id.is_empty()
|| !id
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
{
return Err(GraphError::InvalidId(id.to_string()));
}
Ok(())
}
pub fn heads(&self) -> Vec<&str> {
let mut has_dependents: HashSet<&str> = HashSet::new();
for node in self.nodes.values() {
for dep in &node.migration.dependencies {
has_dependents.insert(dep.as_str());
}
}
self.nodes
.keys()
.filter(|id| !has_dependents.contains(id.as_str()))
.map(String::as_str)
.collect()
}
pub fn next_number(&self) -> u32 {
self.nodes
.keys()
.filter(|id| !id.contains('/'))
.filter_map(|id| id.split('_').next()?.parse::<u32>().ok())
.max()
.map(|n| n + 1)
.unwrap_or(1)
}
pub fn detect_conflict(&self) -> Result<(), GraphError> {
let heads = self.heads();
if heads.len() > 1 {
Err(GraphError::Conflict)
} else {
Ok(())
}
}
pub fn validate_acyclic(&self) -> Result<(), GraphError> {
let mut visited: HashSet<&str> = HashSet::new();
let mut stack: HashSet<&str> = HashSet::new();
for id in self.nodes.keys() {
self.dfs(id, &mut visited, &mut stack)?;
}
Ok(())
}
fn dfs<'a>(
&'a self,
id: &'a str,
visited: &mut HashSet<&'a str>,
stack: &mut HashSet<&'a str>,
) -> Result<(), GraphError> {
if stack.contains(id) {
return Err(GraphError::CycleDetected);
}
if visited.contains(id) {
return Ok(());
}
stack.insert(id);
if let Some(node) = self.nodes.get(id) {
for dep in &node.migration.dependencies {
self.dfs(dep.as_str(), visited, stack)?;
}
}
stack.remove(id);
visited.insert(id);
Ok(())
}
pub fn topological_order(&self) -> Result<Vec<&str>, GraphError> {
self.validate_acyclic()?;
self.validate_dependencies()?;
let mut visited: HashSet<&str> = HashSet::new();
let mut order: Vec<&str> = Vec::new();
let mut ids: Vec<&str> = self.nodes.keys().map(String::as_str).collect();
ids.sort();
for id in ids {
self.topo_visit(id, &mut visited, &mut order);
}
Ok(order)
}
fn validate_dependencies(&self) -> Result<(), GraphError> {
for node in self.nodes.values() {
for dep in &node.migration.dependencies {
if !self.nodes.contains_key(dep.as_str()) {
return Err(GraphError::UnknownDependency {
migration: node.migration.id.clone(),
dep: dep.clone(),
});
}
}
}
Ok(())
}
fn topo_visit<'a>(
&'a self,
id: &'a str,
visited: &mut HashSet<&'a str>,
order: &mut Vec<&'a str>,
) {
if visited.contains(id) {
return;
}
visited.insert(id);
if let Some(node) = self.nodes.get(id) {
let mut deps: Vec<&str> = node
.migration
.dependencies
.iter()
.map(String::as_str)
.collect();
deps.sort();
for dep in deps {
self.topo_visit(dep, visited, order);
}
}
order.push(id);
}
pub fn create_merge_migration(&self, id: String) -> Result<Migration, GraphError> {
let heads = self.heads();
if heads.len() <= 1 {
return Err(GraphError::Empty);
}
let mut dependencies: Vec<String> = heads.iter().map(|s| s.to_string()).collect();
dependencies.sort();
Ok(Migration {
id,
dependencies,
operations: vec![],
atomic: true,
})
}
pub fn get(&self, id: &str) -> Option<&Migration> {
self.nodes.get(id).map(|n| &n.migration)
}
pub fn resolve_id(&self, input: &str) -> Result<String, GraphError> {
if self.nodes.contains_key(input) {
return Ok(input.to_string());
}
let matches: Vec<&str> = self
.topological_order()?
.into_iter()
.filter(|id| id.starts_with(input))
.collect();
match matches.as_slice() {
[] => Err(GraphError::UnknownId(input.to_string())),
[id] => Ok((*id).to_string()),
_ => Err(GraphError::AmbiguousId {
prefix: input.to_string(),
matches: matches.join(", "),
}),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn migration(id: &str, deps: &[&str]) -> Migration {
Migration {
id: id.to_string(),
dependencies: deps.iter().map(|s| s.to_string()).collect(),
operations: vec![],
atomic: true,
}
}
fn linear_graph() -> MigrationGraph {
let mut g = MigrationGraph::new();
g.add(migration("0001_initial", &[])).unwrap();
g.add(migration("0002_add_users", &["0001_initial"]))
.unwrap();
g.add(migration("0003_add_posts", &["0002_add_users"]))
.unwrap();
g
}
#[test]
fn add_duplicate_id_is_error() {
let mut g = MigrationGraph::new();
g.add(migration("0001_initial", &[])).unwrap();
let err = g.add(migration("0001_initial", &[])).unwrap_err();
assert!(matches!(err, GraphError::DuplicateId(id) if id == "0001_initial"));
}
#[test]
fn empty_graph_has_no_heads() {
let g = MigrationGraph::new();
assert!(g.heads().is_empty());
}
#[test]
fn single_migration_is_head() {
let mut g = MigrationGraph::new();
g.add(migration("0001_initial", &[])).unwrap();
assert_eq!(g.heads(), vec!["0001_initial"]);
}
#[test]
fn linear_chain_has_one_head() {
let g = linear_graph();
assert_eq!(g.heads(), vec!["0003_add_posts"]);
}
#[test]
fn branching_graph_has_two_heads() {
let mut g = MigrationGraph::new();
g.add(migration("0001_initial", &[])).unwrap();
g.add(migration("0002_branch_a", &["0001_initial"]))
.unwrap();
g.add(migration("0003_branch_b", &["0001_initial"]))
.unwrap();
let mut heads = g.heads();
heads.sort();
assert_eq!(heads, vec!["0002_branch_a", "0003_branch_b"]);
}
#[test]
fn detect_conflict_ok_for_linear_graph() {
assert!(linear_graph().detect_conflict().is_ok());
}
#[test]
fn detect_conflict_err_for_branched_graph() {
let mut g = MigrationGraph::new();
g.add(migration("0001_initial", &[])).unwrap();
g.add(migration("0002_branch_a", &["0001_initial"]))
.unwrap();
g.add(migration("0003_branch_b", &["0001_initial"]))
.unwrap();
assert!(matches!(g.detect_conflict(), Err(GraphError::Conflict)));
}
#[test]
fn validate_acyclic_passes_for_dag() {
assert!(linear_graph().validate_acyclic().is_ok());
}
#[test]
fn validate_acyclic_detects_cycle() {
let mut g = MigrationGraph::new();
g.add(migration("A", &["B"])).unwrap();
g.add(migration("B", &["A"])).unwrap();
assert!(matches!(
g.validate_acyclic(),
Err(GraphError::CycleDetected)
));
}
#[test]
fn validate_acyclic_detects_longer_cycle() {
let mut g = MigrationGraph::new();
g.add(migration("A", &["C"])).unwrap();
g.add(migration("B", &["A"])).unwrap();
g.add(migration("C", &["B"])).unwrap();
assert!(matches!(
g.validate_acyclic(),
Err(GraphError::CycleDetected)
));
}
#[test]
fn topological_order_linear_chain() {
let g = linear_graph();
let order = g.topological_order().unwrap();
assert_eq!(
order,
vec!["0001_initial", "0002_add_users", "0003_add_posts"]
);
}
#[test]
fn topological_order_respects_dependencies() {
let mut g = MigrationGraph::new();
g.add(migration("0003_c", &["0002_b"])).unwrap();
g.add(migration("0001_a", &[])).unwrap();
g.add(migration("0002_b", &["0001_a"])).unwrap();
let order = g.topological_order().unwrap();
let pos = |id: &str| order.iter().position(|&s| s == id).unwrap();
assert!(pos("0001_a") < pos("0002_b"));
assert!(pos("0002_b") < pos("0003_c"));
}
#[test]
fn topological_order_fails_on_cycle() {
let mut g = MigrationGraph::new();
g.add(migration("A", &["B"])).unwrap();
g.add(migration("B", &["A"])).unwrap();
assert!(matches!(
g.topological_order(),
Err(GraphError::CycleDetected)
));
}
#[test]
fn next_number_empty_graph() {
assert_eq!(MigrationGraph::new().next_number(), 1);
}
#[test]
fn next_number_increments_from_max() {
assert_eq!(linear_graph().next_number(), 4);
}
#[test]
fn next_number_handles_gaps() {
let mut g = MigrationGraph::new();
g.add(migration("0001_a", &[])).unwrap();
g.add(migration("0005_b", &["0001_a"])).unwrap();
assert_eq!(g.next_number(), 6);
}
#[test]
fn create_merge_migration_requires_multiple_heads() {
let err = linear_graph()
.create_merge_migration("merge".to_string())
.unwrap_err();
assert!(matches!(err, GraphError::Empty));
}
#[test]
fn create_merge_migration_depends_on_all_heads() {
let mut g = MigrationGraph::new();
g.add(migration("0001_initial", &[])).unwrap();
g.add(migration("0002_branch_a", &["0001_initial"]))
.unwrap();
g.add(migration("0003_branch_b", &["0001_initial"]))
.unwrap();
let merge = g.create_merge_migration("0004_merge".to_string()).unwrap();
assert_eq!(merge.id, "0004_merge");
assert_eq!(merge.dependencies, vec!["0002_branch_a", "0003_branch_b"]);
assert!(merge.operations.is_empty());
}
#[test]
fn topological_order_fails_on_unknown_dependency() {
let mut g = MigrationGraph::new();
g.add(migration("0002_b", &["0001_ghost"])).unwrap();
let err = g.topological_order().unwrap_err();
assert!(matches!(err, GraphError::UnknownDependency { dep, .. } if dep == "0001_ghost"));
}
#[test]
fn topological_order_stable_regardless_of_insertion_order() {
let mut g1 = MigrationGraph::new();
g1.add(migration("0003_add_posts", &["0002_add_users"]))
.unwrap();
g1.add(migration("0001_initial", &[])).unwrap();
g1.add(migration("0002_add_users", &["0001_initial"]))
.unwrap();
let mut g2 = MigrationGraph::new();
g2.add(migration("0001_initial", &[])).unwrap();
g2.add(migration("0002_add_users", &["0001_initial"]))
.unwrap();
g2.add(migration("0003_add_posts", &["0002_add_users"]))
.unwrap();
assert_eq!(
g1.topological_order().unwrap(),
g2.topological_order().unwrap()
);
assert_eq!(
g1.topological_order().unwrap(),
vec!["0001_initial", "0002_add_users", "0003_add_posts"]
);
}
#[test]
fn topological_order_parallel_branches_alphabetical() {
let mut g = MigrationGraph::new();
g.add(migration("0001_initial", &[])).unwrap();
g.add(migration("0003_feature_b", &["0001_initial"]))
.unwrap();
g.add(migration("0002_feature_a", &["0001_initial"]))
.unwrap();
g.add(migration(
"0004_merge",
&["0002_feature_a", "0003_feature_b"],
))
.unwrap();
let order = g.topological_order().unwrap();
assert_eq!(order[0], "0001_initial");
assert_eq!(order[3], "0004_merge");
let pos = |id: &str| order.iter().position(|&s| s == id).unwrap();
assert!(
pos("0002_feature_a") < pos("0003_feature_b"),
"0002 should come before 0003 alphabetically"
);
assert!(pos("0002_feature_a") < pos("0004_merge"));
assert!(pos("0003_feature_b") < pos("0004_merge"));
}
#[test]
fn topological_order_is_stable_across_calls() {
let mut g = MigrationGraph::new();
g.add(migration("0001_initial", &[])).unwrap();
g.add(migration("0003_z_feature", &["0001_initial"]))
.unwrap();
g.add(migration("0002_a_feature", &["0001_initial"]))
.unwrap();
g.add(migration(
"0004_merge",
&["0002_a_feature", "0003_z_feature"],
))
.unwrap();
let first = g.topological_order().unwrap();
let second = g.topological_order().unwrap();
assert_eq!(first, second);
let pos = |id: &str| first.iter().position(|&s| s == id).unwrap();
assert!(pos("0002_a_feature") < pos("0003_z_feature"));
}
#[test]
fn create_merge_migration_deps_are_always_sorted() {
let mut g = MigrationGraph::new();
g.add(migration("0001_initial", &[])).unwrap();
g.add(migration("0002_zzz_last", &["0001_initial"]))
.unwrap();
g.add(migration("0003_aaa_first", &["0001_initial"]))
.unwrap();
let merge = g.create_merge_migration("0004_merge".to_string()).unwrap();
assert_eq!(merge.dependencies, vec!["0002_zzz_last", "0003_aaa_first"]);
}
#[test]
fn validate_id_rejects_invalid() {
assert!(MigrationGraph::validate_id("").is_err());
assert!(MigrationGraph::validate_id("has space").is_err());
assert!(MigrationGraph::validate_id("HasUpper").is_err());
assert!(MigrationGraph::validate_id("has-dash").is_err());
assert!(MigrationGraph::validate_id("0001_ok").is_ok());
assert!(MigrationGraph::validate_id("abc123").is_ok());
}
#[test]
fn resolve_id_accepts_unique_prefix() {
let graph = linear_graph();
assert_eq!(graph.resolve_id("0002").unwrap(), "0002_add_users");
}
#[test]
fn resolve_id_prefers_exact_match() {
let mut graph = MigrationGraph::new();
graph.add(migration("0001_users", &[])).unwrap();
graph.add(migration("0001_users_index", &[])).unwrap();
assert_eq!(graph.resolve_id("0001_users").unwrap(), "0001_users");
}
#[test]
fn resolve_id_rejects_ambiguous_prefix() {
let graph = linear_graph();
let error = graph.resolve_id("000").unwrap_err();
assert!(matches!(
error,
GraphError::AmbiguousId { prefix, matches }
if prefix == "000" && matches == "0001_initial, 0002_add_users, 0003_add_posts"
));
}
#[test]
fn next_number_ignores_namespaced_ids() {
let mut g = MigrationGraph::new();
g.add(migration("0001_initial", &[])).unwrap();
g.add(migration("auth/0001_users", &[])).unwrap();
g.add(migration("auth/0002_sessions", &[])).unwrap();
assert_eq!(g.next_number(), 2);
}
#[test]
fn next_number_with_only_namespaced_ids() {
let mut g = MigrationGraph::new();
g.add(migration("auth/0005_something", &[])).unwrap();
assert_eq!(g.next_number(), 1);
}
}