mod helpers;
use std::time::Instant;
use helpers::{count_rows, mixed_params, mutate_branch, params, query_main};
use omnigraph::db::{MergeOutcome, Omnigraph};
use omnigraph::error::{MergeConflictKind, OmniError};
use omnigraph::loader::{LoadMode, load_jsonl};
const TRUTH_SCHEMA: &str = r#"
node Person {
name: String @key
age: I32?
}
edge Knows: Person -> Person
"#;
const TRUTH_DATA: &str = r#"{"type":"Person","data":{"name":"Alice","age":30}}
{"type":"Person","data":{"name":"Bob","age":25}}
{"type":"Person","data":{"name":"Carol","age":40}}
{"type":"Person","data":{"name":"Dan","age":50}}
{"edge":"Knows","from":"Bob","to":"Carol"}"#;
const TRUTH_MUTATIONS: &str = r#"
query insert_person($name: String, $age: I32) {
insert Person { name: $name, age: $age }
}
query delete_person($name: String) {
delete Person where name = $name
}
query insert_knows($from: String, $to: String) {
insert Knows { from: $from, to: $to }
}
query delete_knows_from($from: String) {
delete Knows where from = $from
}
query set_person_age($name: String, $age: I32) {
update Person set { age: $age } where name = $name
}
query get_person($name: String) {
match {
$p: Person { name: $name }
}
return { $p.name, $p.age }
}
"#;
async fn bootstrap(dir: &tempfile::TempDir) -> Omnigraph {
let uri = dir.path().to_str().unwrap();
let mut db = Omnigraph::init(uri, TRUTH_SCHEMA).await.unwrap();
load_jsonl(&mut db, TRUTH_DATA, LoadMode::Overwrite)
.await
.unwrap();
db
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[allow(dead_code)] enum OpVariant {
Noop,
AddNode,
RemoveNode,
AddEdge,
RemoveEdge,
SetProperty,
DropProperty,
AddLabel,
RemoveLabel,
}
impl OpVariant {
const ALL: [OpVariant; 9] = [
OpVariant::Noop,
OpVariant::AddNode,
OpVariant::RemoveNode,
OpVariant::AddEdge,
OpVariant::RemoveEdge,
OpVariant::SetProperty,
OpVariant::DropProperty,
OpVariant::AddLabel,
OpVariant::RemoveLabel,
];
fn label(self) -> &'static str {
match self {
OpVariant::Noop => "noop",
OpVariant::AddNode => "addNode",
OpVariant::RemoveNode => "removeNode",
OpVariant::AddEdge => "addEdge",
OpVariant::RemoveEdge => "removeEdge",
OpVariant::SetProperty => "setProperty",
OpVariant::DropProperty => "dropProperty",
OpVariant::AddLabel => "addLabel",
OpVariant::RemoveLabel => "removeLabel",
}
}
}
#[derive(Debug, Clone, Copy)]
enum Apply {
Skip,
InsertEve {
age: i32,
},
DeleteAlice,
InsertAliceCarol,
DeleteKnowsFromBob,
SetAliceAge {
age: i32,
},
}
async fn apply(db: &mut Omnigraph, branch: &str, action: Apply) {
match action {
Apply::Skip => {}
Apply::InsertEve { age } => {
mutate_branch(
db,
branch,
TRUTH_MUTATIONS,
"insert_person",
&mixed_params(&[("$name", "Eve")], &[("$age", age as i64)]),
)
.await
.unwrap();
}
Apply::DeleteAlice => {
mutate_branch(
db,
branch,
TRUTH_MUTATIONS,
"delete_person",
¶ms(&[("$name", "Alice")]),
)
.await
.unwrap();
}
Apply::InsertAliceCarol => {
mutate_branch(
db,
branch,
TRUTH_MUTATIONS,
"insert_knows",
¶ms(&[("$from", "Alice"), ("$to", "Carol")]),
)
.await
.unwrap();
}
Apply::DeleteKnowsFromBob => {
mutate_branch(
db,
branch,
TRUTH_MUTATIONS,
"delete_knows_from",
¶ms(&[("$from", "Bob")]),
)
.await
.unwrap();
}
Apply::SetAliceAge { age } => {
mutate_branch(
db,
branch,
TRUTH_MUTATIONS,
"set_person_age",
&mixed_params(&[("$name", "Alice")], &[("$age", age as i64)]),
)
.await
.unwrap();
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[allow(dead_code)]
enum LanceOutcome {
NotApplicable,
Rebasable,
Retryable,
Incompatible,
}
#[derive(Debug, Clone, Copy)]
struct ConflictMatch {
table_key: &'static str,
kind: MergeConflictKind,
row_id: Option<&'static str>,
}
#[derive(Debug, Clone)]
enum Expected {
AlreadyUpToDate,
FastForward(GraphAssert),
Merged(GraphAssert),
Conflicts(Vec<ConflictMatch>),
Unsupported {
#[allow(dead_code)]
note: &'static str,
},
}
#[derive(Debug, Clone)]
struct GraphAssert {
persons: usize,
knows_edges: usize,
alice_age: Option<i32>,
eve_present: bool,
}
impl GraphAssert {
const fn base() -> Self {
Self {
persons: 4,
knows_edges: 1,
alice_age: Some(30),
eve_present: false,
}
}
const fn with_alice_age(mut self, age: i32) -> Self {
self.alice_age = Some(age);
self
}
const fn without_alice(mut self) -> Self {
self.alice_age = None;
self.persons -= 1;
self
}
const fn with_eve(mut self) -> Self {
self.eve_present = true;
self.persons += 1;
self
}
const fn with_extra_knows_edge(mut self) -> Self {
self.knows_edges += 1;
self
}
const fn without_bob_carol(mut self) -> Self {
self.knows_edges -= 1;
self
}
}
#[derive(Debug, Clone)]
struct MergeCase {
left: OpVariant,
right: OpVariant,
apply_left: Apply,
apply_right: Apply,
expected: Expected,
#[allow(dead_code)]
lance: LanceOutcome,
#[allow(dead_code)]
note: &'static str,
}
impl MergeCase {
fn unsupported(left: OpVariant, right: OpVariant, note: &'static str) -> Self {
Self {
left,
right,
apply_left: Apply::Skip,
apply_right: Apply::Skip,
expected: Expected::Unsupported { note },
lance: LanceOutcome::NotApplicable,
note,
}
}
}
fn build_case(left: OpVariant, right: OpVariant) -> MergeCase {
use Apply::*;
use OpVariant::*;
let mk = |al: Apply, ar: Apply, exp: Expected, note: &'static str| MergeCase {
left,
right,
apply_left: al,
apply_right: ar,
expected: exp,
lance: LanceOutcome::NotApplicable,
note,
};
let conflict = |kind: MergeConflictKind, table: &'static str, row: Option<&'static str>| {
Expected::Conflicts(vec![ConflictMatch {
table_key: table,
kind,
row_id: row,
}])
};
match (left, right) {
(Noop, Noop) => mk(
Skip,
Skip,
Expected::AlreadyUpToDate,
"no change either side",
),
(Noop, AddNode) => mk(
Skip,
InsertEve { age: 22 },
Expected::AlreadyUpToDate,
"source unchanged → up to date",
),
(Noop, RemoveNode) => mk(
Skip,
DeleteAlice,
Expected::AlreadyUpToDate,
"source unchanged → up to date",
),
(Noop, AddEdge) => mk(
Skip,
InsertAliceCarol,
Expected::AlreadyUpToDate,
"source unchanged → up to date",
),
(Noop, RemoveEdge) => mk(
Skip,
DeleteKnowsFromBob,
Expected::AlreadyUpToDate,
"source unchanged → up to date",
),
(Noop, SetProperty) => mk(
Skip,
SetAliceAge { age: 31 },
Expected::AlreadyUpToDate,
"source unchanged → up to date",
),
(AddNode, Noop) => mk(
InsertEve { age: 22 },
Skip,
Expected::FastForward(GraphAssert::base().with_eve()),
"target unchanged → fast-forward",
),
(AddNode, AddNode) => mk(
InsertEve { age: 21 },
InsertEve { age: 22 },
conflict(
MergeConflictKind::DivergentInsert,
"node:Person",
Some("Eve"),
),
"both sides insert Eve with different ages",
),
(AddNode, RemoveNode) => mk(
InsertEve { age: 22 },
DeleteAlice,
Expected::Merged(GraphAssert::base().with_eve().without_alice()),
"disjoint: insert + delete different nodes",
),
(AddNode, AddEdge) => mk(
InsertEve { age: 22 },
InsertAliceCarol,
Expected::Merged(GraphAssert::base().with_eve().with_extra_knows_edge()),
"disjoint: insert node + insert unrelated edge",
),
(AddNode, RemoveEdge) => mk(
InsertEve { age: 22 },
DeleteKnowsFromBob,
Expected::Merged(GraphAssert::base().with_eve().without_bob_carol()),
"disjoint: insert node + delete edge",
),
(AddNode, SetProperty) => mk(
InsertEve { age: 22 },
SetAliceAge { age: 31 },
Expected::Merged(GraphAssert::base().with_eve().with_alice_age(31)),
"disjoint: insert node + update other node",
),
(RemoveNode, Noop) => mk(
DeleteAlice,
Skip,
Expected::FastForward(GraphAssert::base().without_alice()),
"target unchanged → fast-forward",
),
(RemoveNode, AddNode) => mk(
DeleteAlice,
InsertEve { age: 22 },
Expected::Merged(GraphAssert::base().without_alice().with_eve()),
"disjoint: delete + insert different nodes",
),
(RemoveNode, RemoveNode) => mk(
DeleteAlice,
DeleteAlice,
Expected::Merged(GraphAssert::base().without_alice()),
"both sides delete Alice — idempotent",
),
(RemoveNode, AddEdge) => mk(
DeleteAlice,
InsertAliceCarol,
conflict(MergeConflictKind::OrphanEdge, "edge:Knows", None),
"delete Alice on left races edge that needs Alice as src",
),
(RemoveNode, RemoveEdge) => mk(
DeleteAlice,
DeleteKnowsFromBob,
Expected::Merged(GraphAssert::base().without_alice().without_bob_carol()),
"disjoint: delete node + delete unrelated edge",
),
(RemoveNode, SetProperty) => mk(
DeleteAlice,
SetAliceAge { age: 31 },
conflict(
MergeConflictKind::DeleteVsUpdate,
"node:Person",
Some("Alice"),
),
"delete vs update on same row",
),
(AddEdge, Noop) => mk(
InsertAliceCarol,
Skip,
Expected::FastForward(GraphAssert::base().with_extra_knows_edge()),
"target unchanged → fast-forward",
),
(AddEdge, AddNode) => mk(
InsertAliceCarol,
InsertEve { age: 22 },
Expected::Merged(GraphAssert::base().with_extra_knows_edge().with_eve()),
"disjoint: insert edge + insert unrelated node",
),
(AddEdge, RemoveNode) => mk(
InsertAliceCarol,
DeleteAlice,
conflict(MergeConflictKind::OrphanEdge, "edge:Knows", None),
"edge needs Alice as src; target deleted Alice",
),
(AddEdge, AddEdge) => mk(
InsertAliceCarol,
InsertAliceCarol,
Expected::Merged(GraphAssert {
persons: 4,
knows_edges: 3,
alice_age: Some(30),
eve_present: false,
}),
"both insert Alice→Carol; current edge model preserves duplicates",
),
(AddEdge, RemoveEdge) => mk(
InsertAliceCarol,
DeleteKnowsFromBob,
Expected::Merged(GraphAssert::base()),
"disjoint: insert one edge + delete different edge",
),
(AddEdge, SetProperty) => mk(
InsertAliceCarol,
SetAliceAge { age: 31 },
Expected::Merged(
GraphAssert::base()
.with_extra_knows_edge()
.with_alice_age(31),
),
"disjoint: insert edge involving Alice + update Alice.age",
),
(RemoveEdge, Noop) => mk(
DeleteKnowsFromBob,
Skip,
Expected::FastForward(GraphAssert::base().without_bob_carol()),
"target unchanged → fast-forward",
),
(RemoveEdge, AddNode) => mk(
DeleteKnowsFromBob,
InsertEve { age: 22 },
Expected::Merged(GraphAssert::base().without_bob_carol().with_eve()),
"disjoint: delete edge + insert node",
),
(RemoveEdge, RemoveNode) => mk(
DeleteKnowsFromBob,
DeleteAlice,
Expected::Merged(GraphAssert::base().without_bob_carol().without_alice()),
"disjoint: delete edge + delete unrelated node",
),
(RemoveEdge, AddEdge) => mk(
DeleteKnowsFromBob,
InsertAliceCarol,
Expected::Merged(GraphAssert::base()),
"disjoint: delete one edge + insert another",
),
(RemoveEdge, RemoveEdge) => mk(
DeleteKnowsFromBob,
DeleteKnowsFromBob,
Expected::Merged(GraphAssert::base().without_bob_carol()),
"both sides delete Bob→Carol — idempotent",
),
(RemoveEdge, SetProperty) => mk(
DeleteKnowsFromBob,
SetAliceAge { age: 31 },
Expected::Merged(GraphAssert::base().without_bob_carol().with_alice_age(31)),
"disjoint: delete edge + update unrelated node",
),
(SetProperty, Noop) => mk(
SetAliceAge { age: 31 },
Skip,
Expected::FastForward(GraphAssert::base().with_alice_age(31)),
"target unchanged → fast-forward",
),
(SetProperty, AddNode) => mk(
SetAliceAge { age: 31 },
InsertEve { age: 22 },
Expected::Merged(GraphAssert::base().with_alice_age(31).with_eve()),
"disjoint: update + insert",
),
(SetProperty, RemoveNode) => mk(
SetAliceAge { age: 31 },
DeleteAlice,
conflict(
MergeConflictKind::DeleteVsUpdate,
"node:Person",
Some("Alice"),
),
"update vs delete on same row",
),
(SetProperty, AddEdge) => mk(
SetAliceAge { age: 31 },
InsertAliceCarol,
Expected::Merged(
GraphAssert::base()
.with_alice_age(31)
.with_extra_knows_edge(),
),
"disjoint: update Alice + insert edge involving Alice",
),
(SetProperty, RemoveEdge) => mk(
SetAliceAge { age: 31 },
DeleteKnowsFromBob,
Expected::Merged(GraphAssert::base().with_alice_age(31).without_bob_carol()),
"disjoint: update node + delete edge",
),
(SetProperty, SetProperty) => mk(
SetAliceAge { age: 31 },
SetAliceAge { age: 32 },
conflict(
MergeConflictKind::DivergentUpdate,
"node:Person",
Some("Alice"),
),
"both sides set Alice.age to different values",
),
(DropProperty, _) | (_, DropProperty) => {
MergeCase::unsupported(left, right, "dropProperty: not in mutation grammar today")
}
(AddLabel, _) | (_, AddLabel) => {
MergeCase::unsupported(left, right, "addLabel: schema has no first-class label op")
}
(RemoveLabel, _) | (_, RemoveLabel) => MergeCase::unsupported(
left,
right,
"removeLabel: schema has no first-class label op",
),
}
}
#[derive(Debug)]
struct DirectionResult {
#[allow(dead_code)]
cell: String,
outcome: ActualOutcome,
}
#[derive(Debug, PartialEq)]
enum ActualOutcome {
AlreadyUpToDate,
FastForward,
Merged,
Conflicts(Vec<(String, MergeConflictKind, Option<String>)>),
Skipped,
}
async fn run_direction(
label: &str,
left_op: Apply,
right_op: Apply,
expected: &Expected,
) -> DirectionResult {
let dir = tempfile::tempdir().unwrap();
let mut db = bootstrap(&dir).await;
db.branch_create("feature").await.unwrap();
apply(&mut db, "feature", left_op).await;
apply(&mut db, "main", right_op).await;
let merge_result = db.branch_merge("feature", "main").await;
let outcome = match merge_result {
Ok(MergeOutcome::AlreadyUpToDate) => ActualOutcome::AlreadyUpToDate,
Ok(MergeOutcome::FastForward) => ActualOutcome::FastForward,
Ok(MergeOutcome::Merged) => ActualOutcome::Merged,
Err(OmniError::MergeConflicts(conflicts)) => ActualOutcome::Conflicts(
conflicts
.into_iter()
.map(|c| (c.table_key, c.kind, c.row_id))
.collect(),
),
Err(other) => panic!("[{label}] unexpected merge error: {other:?}"),
};
if let Expected::Merged(assert) | Expected::FastForward(assert) = expected
&& matches!(
outcome,
ActualOutcome::Merged | ActualOutcome::FastForward | ActualOutcome::AlreadyUpToDate
)
{
assert_state(&mut db, assert, label).await;
}
if matches!(outcome, ActualOutcome::Conflicts(_)) {
let expected_target = state_after_apply_only(right_op);
assert_state(&mut db, &expected_target, label).await;
}
DirectionResult {
cell: label.to_string(),
outcome,
}
}
fn state_after_apply_only(action: Apply) -> GraphAssert {
match action {
Apply::Skip => GraphAssert::base(),
Apply::InsertEve { .. } => GraphAssert::base().with_eve(),
Apply::DeleteAlice => GraphAssert::base().without_alice(),
Apply::InsertAliceCarol => GraphAssert::base().with_extra_knows_edge(),
Apply::DeleteKnowsFromBob => GraphAssert::base().without_bob_carol(),
Apply::SetAliceAge { age } => GraphAssert::base().with_alice_age(age),
}
}
async fn assert_state(db: &mut Omnigraph, expected: &GraphAssert, label: &str) {
let person_count = count_rows(db, "node:Person").await;
assert_eq!(
person_count, expected.persons,
"[{label}] node:Person count"
);
let knows_count = count_rows(db, "edge:Knows").await;
assert_eq!(
knows_count, expected.knows_edges,
"[{label}] edge:Knows count"
);
let alice = query_main(
db,
TRUTH_MUTATIONS,
"get_person",
¶ms(&[("$name", "Alice")]),
)
.await
.unwrap();
match expected.alice_age {
None => assert_eq!(alice.num_rows(), 0, "[{label}] Alice should be absent"),
Some(age) => {
assert_eq!(alice.num_rows(), 1, "[{label}] Alice should be present");
let batch = alice.concat_batches().unwrap();
let ages = batch
.column(1)
.as_any()
.downcast_ref::<arrow_array::Int32Array>()
.unwrap();
assert_eq!(ages.value(0), age, "[{label}] Alice.age");
}
}
let eve = query_main(
db,
TRUTH_MUTATIONS,
"get_person",
¶ms(&[("$name", "Eve")]),
)
.await
.unwrap();
assert_eq!(
eve.num_rows(),
usize::from(expected.eve_present),
"[{label}] Eve presence mismatch"
);
}
fn check_outcome(label: &str, expected: &Expected, actual: &ActualOutcome) {
match (expected, actual) {
(Expected::AlreadyUpToDate, ActualOutcome::AlreadyUpToDate)
| (Expected::FastForward(_), ActualOutcome::FastForward)
| (Expected::Merged(_), ActualOutcome::Merged) => {}
(Expected::Conflicts(want), ActualOutcome::Conflicts(got)) => {
assert_eq!(
got.len(),
want.len(),
"[{label}] expected {} conflict(s) but got {}: {got:?}",
want.len(),
got.len()
);
for w in want {
let hit = got.iter().any(|(table, kind, row)| {
table == w.table_key
&& *kind == w.kind
&& match w.row_id {
None => true,
Some(expected_row) => row.as_deref() == Some(expected_row),
}
});
assert!(
hit,
"[{label}] expected conflict {{table={}, kind={:?}, row={:?}}} not found in {:?}",
w.table_key, w.kind, w.row_id, got
);
}
}
(Expected::Unsupported { .. }, ActualOutcome::Skipped) => {}
_ => panic!("[{label}] expected {expected:?}, got {actual:?}"),
}
}
async fn run_cell(case: &MergeCase) -> DirectionResult {
let label = format!("{}×{}", case.left.label(), case.right.label());
if matches!(case.expected, Expected::Unsupported { .. }) {
return DirectionResult {
cell: label,
outcome: ActualOutcome::Skipped,
};
}
let result = run_direction(&label, case.apply_left, case.apply_right, &case.expected).await;
check_outcome(&label, &case.expected, &result.outcome);
result
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn merge_pair_truth_table() {
let start = Instant::now();
let mut total_cells = 0_usize;
let mut executable_cells = 0_usize;
let mut unsupported_cells = 0_usize;
let mut directions_run = 0_usize;
for left in OpVariant::ALL {
for right in OpVariant::ALL {
total_cells += 1;
let case = build_case(left, right);
if matches!(case.expected, Expected::Unsupported { .. }) {
unsupported_cells += 1;
} else {
executable_cells += 1;
}
let result = run_cell(&case).await;
if !matches!(result.outcome, ActualOutcome::Skipped) {
directions_run += 1;
}
}
}
let elapsed = start.elapsed();
println!(
"merge truth table: {} cells total ({} executable, {} unsupported), {} executions in {:.2}s",
total_cells,
executable_cells,
unsupported_cells,
directions_run,
elapsed.as_secs_f64(),
);
assert_eq!(total_cells, 81, "truth table must enumerate all 81 cells");
assert_eq!(
executable_cells, 36,
"expected 6×6 executable cells under the current mutation grammar"
);
assert_eq!(
unsupported_cells, 45,
"expected 45 cells involving dropProperty/addLabel/removeLabel"
);
}