fix: Deep-merge nested data documents in Engine::add_data#760
Conversation
add_data previously performed a shallow merge: adding a nested object under a key that already existed either replaced the whole subtree or errored on a spurious conflict, instead of merging the trees. This makes Engine::add_data (and the shared Value::merge) recurse into nested objects so keys from both sides are preserved, matching OPA's data-document merge semantics. Nested sets are unioned as a regorus extension (OPA data is JSON and has no sets). Genuine leaf conflicts (same path, two different scalar values) still error; equal values remain a no-op, which the shared rule-evaluation path relies on. Adds tests for object deep-merge, set union, leaf/type conflicts, and interaction with the 'with data.x' modifier. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
749d710 to
efff5c4
Compare
There was a problem hiding this comment.
Pull request overview
This PR fixes Engine::add_data to follow OPA-style deep-merge semantics for nested objects by making Value::merge recursive for object values (and unioning nested sets as a regorus extension). This aligns repeated add_data calls with expected data-document composition behavior without spurious conflicts or subtree replacement.
Changes:
- Update
Value::mergeto recursively merge nested objects and union nested sets, while still erroring on genuine leaf conflicts (and tolerating equal-values as a no-op). - Add interpreter tests covering deep-merge behavior, conflicts, set union, and
with data.* as ...replacement semantics. - Update
Engine::add_datadocs/doctest andCHANGELOG.mdto document the new merge behavior.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| src/value/mod.rs | Implements recursive object merge and nested set union in Value::merge, updating merge semantics used by Engine::add_data. |
| src/tests/interpreter/mod.rs | Adds tests to validate deep-merge behavior, conflicts, set union, and with data.* replacement behavior. |
| src/engine.rs | Updates Engine::add_data documentation/doctest to reflect deep-merge behavior and conflict rules. |
| CHANGELOG.md | Notes the deep-merge behavior change in the Unreleased section. |
| let both_mergeable = matches!( | ||
| (&*existing, v), | ||
| (Value::Object(_), Value::Object(_)) | ||
| | (Value::Set(_), Value::Set(_)) | ||
| ); | ||
| if both_mergeable { | ||
| existing.merge(v.clone())?; | ||
| } else if *existing != *v { |
There was a problem hiding this comment.
Optimized. The (Set, Set) arm no longer calls Rc::make_mut(new). It now mem::takes the RHS and Rc::try_unwraps it: elements are moved out when the set is uniquely owned, and only the per-element Rc handles are cloned when it's shared (the v.clone() path).
Copilot review on microsoft#760 noted the doc comment called non-mergeable variants 'non-container values', which is misleading since arrays are containers yet still conflict unless equal. Reword to describe a conflict as any differing pair that is not both objects or both sets (e.g. unequal scalars or arrays). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
When unioning sets in Value::merge, the RHS set is often shared: the object arm recurses via existing.merge(v.clone()), which bumps the incoming set's Rc refcount. The old Rc::make_mut(new) then structurally deep-cloned the entire RHS BTreeSet just to drain it via append and immediately discard the copy. Move the elements out when the RHS set is uniquely owned, and otherwise clone only the per-element Rc handles into the destination. The union result is identical (BTreeSet dedups), but no throwaway set is allocated on the nested-merge path exercised by add_data deep-merge. Addresses a Copilot review comment on microsoft#760. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
🐛
|
Now that Value::merge recurses, a conflict in a later nested key was reported only after earlier keys of the same document had already been written into the live init_data, leaving the engine partially mutated on a rejected add_data. Add a read-only Value::check_mergeable that mirrors merge's conflict rule (objects deep-merge, sets union, equal values no-op, anything else conflicts) and run it in add_data before merging. On conflict nothing is mutated, so add_data is all-or-nothing. The check allocates nothing and never copies the data spine, preserving merge's in-place uniquely-owned fast path (no candidate copy of the data document). Adds regression tests for a partial object-leaf conflict and a partial set-union conflict. Reported by a maintainer on microsoft#760. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Arrays are atomic leaves, so a differing array at a shared path is a conflict. The new key sorts before the conflicting array key, so a naive in-place merge would leak the new key before hitting the conflict. This test locks in that add_data rejects the whole call and leaves data untouched. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
|
Thanks @anakrish , good catch. Fixed. I went with validate-then-merge instead of candidate-copy. Candidate-copy costs even on success: after eval, self.interpreter.get_init_data().check_mergeable(&data)?;
self.prepared = false;
self.interpreter.get_init_data_mut().merge(data)The read-only check is O(overlap), allocates nothing, and short-circuits at absent keys (new-key adds never walk the payload), so the happy path stays zero-copy and in-place. |
🔁 Second-round review (post-update)Thanks for the atomicity fix — the conflict path is now correctly all-or-nothing. Re-reviewing the update surfaced two new issues plus some hardening items. Findings below are ordered by severity; the two most important are #1 and #2. 1. 🔴 HIGH — Atomicity is still broken for the allocator-limit failure mode
map.insert(k.clone(), v.clone());
enforce_limit_anyhow()?; // <-- fails AFTER the insertSo on an
Suggested fix — merge into a candidate copy and commit only on success. pub fn add_data(&mut self, data: Value) -> Result<()> {
if data.as_object().is_err() {
bail!("data must be object");
}
// All-or-nothing: merge into a candidate, commit only on success. Covers both
// semantic conflicts AND allocator-limit failures. Rc copy-on-write means only
// the touched subtrees are cloned.
let mut candidate = self.interpreter.get_init_data().clone();
candidate.merge(data)?;
*self.interpreter.get_init_data_mut() = candidate;
self.prepared = false;
Ok(())
}Regression test (needs the 2. 🟠 MEDIUM — Zero-arg function conflicts now silently deep-merge instead of erroring
An authoring mistake that used to be a hard error now silently produces a merged value. It's narrow (zero-arg functions + a shared key whose values are both objects/sets), but it's a silent evaluation-semantics change. (Note: disjoint object keys already merged pre-PR, so that specific case is not a regression.) Suggested fix — don't let the data-document deep-merge leak into rule materialization. Either give Regression test (add to #[test]
fn test_zero_arg_function_object_conflict_errors() -> Result<()> {
let mut engine = Engine::new();
engine.add_policy(
"p.rego".to_string(),
r#"
package test
f() := {"a": {"x": 1}}
f() := {"a": {"y": 2}}
"#
.to_string(),
)?;
// Two zero-arg definitions producing objects that share a key must conflict,
// NOT silently deep-merge.
assert!(engine
.eval_query("data.test.f".to_string(), false)
.is_err());
Ok(())
}3. 🟡 MEDIUM — Unconditional
|
Summary
Engine::add_dataperformed a shallow merge. Adding a nested object under a key that already existed would either replace the existing subtree wholesale or raise a spuriousgenerated multiple timesconflict, instead of deep-merging the two data trees.
rust engine.add_data(Value::from_json_str(r#"{ "y": { "a": 10 } }"#)?)?; engine.add_data(Value::from_json_str(r#"{ "y": { "b": 20 } }"#)?)?; // previously errored / dropped "a" // data["y"] is now { "a": 10, "b": 20 } Changes
Value::mergenow recurses into nested objects, so keys from both sides are preserved — matching OPA's data-document merge semantics (internal/merge/merge.go).Interpreter::merge_rule_value) relies on, since a rule may legally produce the same value more than once.Engine::add_datadoctest andCHANGELOG.mdupdated to reflect the deep-merge behavior.The
with data.x as ...modifier is unaffected: it replaces the targeted subtree directly and does not go throughValue::merge.Tests
Added to
src/tests/interpreter/mod.rs:with data.xmodifier replace semantics (nested replace preserves siblings; whole-subtree replace).Validation
cargo test --lib: all passing.cargo test --test opa: pass/fail counts identical tomain— no conformance regression.cargo xtask pre-commit(rustfmt + clippy-Dwarnings): clean.