Skip to content

Commit e0466f8

Browse files
Mark BirgerCopilot
andcommitted
fix: separate strict rule-output merge from data-document deep-merge
microsoft#760 made Value::merge recursive so Engine::add_data deep-merges nested data documents. But that same method also backs rule materialization, where recursion is wrong: two rule definitions producing different outputs for one path must conflict (OPA complete-rule semantics), not silently combine. Split the two behaviors: - Value::merge is strict and shallow again (as pre-microsoft#760): a key on both sides must be equal or it conflicts; used for rule outputs. - Value::deep_merge is the recursive data-document merge behind add_data; check_mergeable validates it up front without allocating, so the default build merges in place instead of cloning a candidate. Also fix zero-arg functions (f() := ...): route their materialization through strict equality via a new RuleValueMerge selector, so disjoint outputs ({a:1} vs {b:2}) conflict as OPA does while prefix scaffolding (a.foo + a.bar) still combines. Add a 14-case interpreter conformance matrix (multiple_outputs.yaml) covering functions, static/dynamic partial objects, and ref-heads, matched against OPA v1.2.0. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 35684db commit e0466f8

4 files changed

Lines changed: 345 additions & 51 deletions

File tree

src/engine.rs

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -475,26 +475,24 @@ impl Engine {
475475
bail!("data must be object");
476476
}
477477

478-
// add_data is all-or-nothing. A merge can fail two ways, and the atomic strategy
479-
// differs by build:
480-
// - conflict (same path, two different values): possible on every build.
481-
// - allocator-limit failure mid-merge: only when `allocator-memory-limits` is on;
482-
// otherwise the limit check is a compile-time no-op.
478+
// add_data is all-or-nothing; the atomic strategy differs by build because the failure
479+
// modes do: a conflict (same path, differing values) is possible everywhere, an
480+
// allocator-limit failure mid-merge only under `allocator-memory-limits`.
483481
#[cfg(not(feature = "allocator-memory-limits"))]
484482
{
485-
// Conflict is the only failure mode, and `check_mergeable` catches it up front
486-
// without allocatingso validate, then merge in place (zero-copy fast path).
483+
// Conflict is the only failure mode; `check_mergeable` catches it up front without
484+
// allocating, so validate then deep-merge in place (zero-copy fast path).
487485
self.interpreter.get_init_data().check_mergeable(&data)?;
488486
self.prepared = false;
489-
self.interpreter.get_init_data_mut().merge(data)
487+
self.interpreter.get_init_data_mut().deep_merge(data)
490488
}
491489
#[cfg(feature = "allocator-memory-limits")]
492490
{
493-
// A limit failure can strike mid-merge and `check_mergeable` can't predict it,
494-
// so merge into a candidate and commit only on success — atomic for both failure
495-
// modes. `Value` is Rc/copy-on-write, so only touched subtrees are cloned.
491+
// A limit failure can strike mid-merge and can't be predicted, so merge into a
492+
// candidate and commit only on success. `Value` is copy-on-write, so only touched
493+
// subtrees are cloned.
496494
let mut candidate = self.interpreter.get_init_data().clone();
497-
candidate.merge(data)?;
495+
candidate.deep_merge(data)?;
498496
*self.interpreter.get_init_data_mut() = candidate;
499497
self.prepared = false;
500498
Ok(())

src/interpreter.rs

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,17 @@ enum FunctionModifier {
6060
Value(Value),
6161
}
6262

63+
/// How [`Interpreter::update_data`] merges a rule's value into the data document.
64+
#[derive(Debug, Clone, Copy)]
65+
enum RuleValueMerge {
66+
/// Shallow-merge keeping disjoint keys, so rules sharing a path prefix scaffold into one
67+
/// object (`a.foo` + `a.bar` → one `a`) instead of conflicting.
68+
Combine,
69+
/// Complete-rule semantics: existing value must be absent or exactly equal, else conflict.
70+
/// Used for zero-arg function outputs (`f() := …`), which OPA treats like complete rules.
71+
Strict,
72+
}
73+
6374
type RuleValues = BTreeMap<Vec<Value>, (Value, Ref<Expr>)>;
6475

6576
#[derive(Debug)]
@@ -3408,6 +3419,23 @@ impl Interpreter {
34083419
}
34093420
}
34103421

3422+
/// Materialize a complete-rule value: the existing value must be absent or *exactly equal*
3423+
/// to `new`, else it is a conflict.
3424+
///
3425+
/// Unlike the shallow [`Self::merge_rule_value`], differing outputs conflict instead of
3426+
/// combining — `f() := {"a": 1}` and `f() := {"b": 2}` conflict — matching OPA's semantics
3427+
/// for zero-arg functions.
3428+
fn merge_rule_value_strict(span: &Span, value: &mut Value, new: Value) -> Result<()> {
3429+
if *value == Value::Undefined {
3430+
*value = new;
3431+
Ok(())
3432+
} else if *value == new {
3433+
Ok(())
3434+
} else {
3435+
Err(span.error("rules should not produce multiple outputs."))
3436+
}
3437+
}
3438+
34113439
pub fn get_path_string(refr: &Expr, document: Option<&str>) -> Result<String> {
34123440
let mut comps = vec![];
34133441
let mut expr_opt = Some(refr);
@@ -3663,14 +3691,18 @@ impl Interpreter {
36633691
_refr: &Expr,
36643692
path: &[&str],
36653693
value: Value,
3694+
merge: RuleValueMerge,
36663695
) -> Result<()> {
36673696
if value == Value::Undefined {
36683697
return Ok(());
36693698
}
36703699
// Ensure that path is created.
36713700
let vref = Self::make_or_get_value_mut(&mut self.data, path)?;
36723701
if Self::get_value_chained(self.init_data.clone(), path) == Value::Undefined {
3673-
Self::merge_rule_value(span, vref, value)
3702+
match merge {
3703+
RuleValueMerge::Strict => Self::merge_rule_value_strict(span, vref, value),
3704+
RuleValueMerge::Combine => Self::merge_rule_value(span, vref, value),
3705+
}
36743706
} else {
36753707
// Retain specified value.
36763708
Ok(())
@@ -3778,7 +3810,13 @@ impl Interpreter {
37783810
// `a` is created as an empty object.
37793811
if let Some((_, prefix)) = path.split_last() {
37803812
if !prefix.is_empty() {
3781-
self.update_data(span, refr, prefix, Value::new_object())?;
3813+
self.update_data(
3814+
span,
3815+
refr,
3816+
prefix,
3817+
Value::new_object(),
3818+
RuleValueMerge::Combine,
3819+
)?;
37823820
}
37833821
}
37843822

@@ -3790,7 +3828,13 @@ impl Interpreter {
37903828
};
37913829

37923830
let value = self.eval_rule_bodies(ctx, span, rule_body)?;
3793-
self.update_data(refr.span(), refr, &path[..], value)?;
3831+
self.update_data(
3832+
refr.span(),
3833+
refr,
3834+
&path[..],
3835+
value,
3836+
RuleValueMerge::Strict,
3837+
)?;
37943838
}
37953839
}
37963840
}
@@ -4037,6 +4081,7 @@ impl Interpreter {
40374081
rule_refr,
40384082
&prefix_path,
40394083
Value::new_object(),
4084+
RuleValueMerge::Combine,
40404085
)?;
40414086
}
40424087
}

src/value/mod.rs

Lines changed: 64 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1365,55 +1365,87 @@ impl Value {
13651365
}
13661366
}
13671367

1368-
/// Recursively merge `new` into `self`.
1369-
///
1370-
/// Objects are deep-merged: keys present on only one side are kept, keys present on both
1371-
/// whose values are both objects are merged recursively, and keys whose values are both
1372-
/// sets are unioned. This matches OPA's data-document merge semantics for objects (see
1373-
/// `Engine::add_data`); set-union is a regorus extension, since OPA data documents are
1374-
/// JSON and cannot contain sets. Any other differing pair — values that are not both
1375-
/// objects or both sets, such as two unequal scalars or two arrays — is a genuine
1376-
/// conflict and is an error. Equal values are tolerated as a no-op;
1377-
/// the rule-evaluation path (`Interpreter::merge_rule_value`) shares this method and relies
1378-
/// on that leniency, since a rule may legally produce the same value more than once.
1368+
/// Shallow-merge `new` into `self` with strict rule-output semantics.
1369+
///
1370+
/// Objects merge one level deep: a key on both sides must hold the *same* value or it is a
1371+
/// conflict; sets union; equal values are a no-op. Non-recursive by design — data documents
1372+
/// use [`Value::deep_merge`] instead.
13791373
pub(crate) fn merge(&mut self, mut new: Value) -> Result<()> {
13801374
if self == &new {
13811375
return Ok(());
13821376
}
13831377
match (self, &mut new) {
13841378
(v @ Value::Undefined, _) => *v = new,
13851379
(Value::Set(ref mut set), Value::Set(new)) => {
1386-
// Union without deep-cloning the RHS set. If we uniquely own it, move its
1387-
// elements out; if it is shared (e.g. reached via `existing.merge(v.clone())`
1388-
// in the object arm below), clone only the element handles (`Rc` bumps),
1389-
// never the whole `BTreeSet`.
1380+
// Union without deep-cloning the RHS set (see `deep_merge`).
1381+
let dst = Rc::make_mut(set);
1382+
match Rc::try_unwrap(core::mem::take(new)) {
1383+
Ok(owned) => dst.extend(owned),
1384+
Err(shared) => dst.extend(shared.iter().cloned()),
1385+
}
1386+
enforce_limit_anyhow()?;
1387+
}
1388+
(Value::Object(map), Value::Object(new)) => {
1389+
for (k, v) in new.iter() {
1390+
match map.get(k) {
1391+
// Same key, different value: the rule produced two outputs for one path.
1392+
Some(pv) if *pv != *v => bail!(
1393+
"value for key `{}` generated multiple times: `{}` and `{}`",
1394+
serde_json::to_string_pretty(&k).map_err(anyhow::Error::msg)?,
1395+
serde_json::to_string_pretty(&pv).map_err(anyhow::Error::msg)?,
1396+
serde_json::to_string_pretty(&v).map_err(anyhow::Error::msg)?,
1397+
),
1398+
_ => {
1399+
Rc::make_mut(map).insert(k.clone(), v.clone());
1400+
enforce_limit_anyhow()?;
1401+
}
1402+
};
1403+
}
1404+
}
1405+
_ => bail!("error: could not merge value"),
1406+
};
1407+
Ok(())
1408+
}
1409+
1410+
/// Recursively deep-merge `new` into `self` — the data-document merge behind [`Engine::add_data`].
1411+
///
1412+
/// Objects recurse per-key, sets union, equal values are a no-op, any other differing pair
1413+
/// conflicts. Set-union is a regorus extension (OPA data is JSON, which has no sets). Distinct
1414+
/// from the strict, non-recursive [`Value::merge`] used for rule outputs — use deep-merge ONLY
1415+
/// for data documents.
1416+
///
1417+
/// [`Engine::add_data`]: crate::Engine::add_data
1418+
pub(crate) fn deep_merge(&mut self, mut new: Value) -> Result<()> {
1419+
if self == &new {
1420+
return Ok(());
1421+
}
1422+
match (self, &mut new) {
1423+
(v @ Value::Undefined, _) => *v = new,
1424+
(Value::Set(ref mut set), Value::Set(new)) => {
1425+
// Union without deep-cloning the RHS set: move elements if uniquely owned,
1426+
// else clone only the element handles (`Rc` bumps), never the whole `BTreeSet`.
13901427
let dst = Rc::make_mut(set);
13911428
match Rc::try_unwrap(core::mem::take(new)) {
13921429
Ok(owned) => dst.extend(owned),
13931430
Err(shared) => dst.extend(shared.iter().cloned()),
13941431
}
1395-
// Enforce allocator limit after merging set entries.
13961432
enforce_limit_anyhow()?;
13971433
}
13981434
(Value::Object(map), Value::Object(new)) => {
13991435
let map = Rc::make_mut(map);
14001436
for (k, v) in new.iter() {
14011437
match map.get_mut(k) {
14021438
Some(existing) => {
1403-
// Deep-merge: when both sides are containers, recurse so that
1404-
// nested objects are merged rather than the whole subtree being
1405-
// replaced (matches OPA's data document merge semantics).
1439+
// When both sides are containers, recurse so nested objects merge
1440+
// rather than the subtree being replaced (OPA data-merge semantics).
14061441
let both_mergeable = matches!(
14071442
(&*existing, v),
14081443
(Value::Object(_), Value::Object(_))
14091444
| (Value::Set(_), Value::Set(_))
14101445
);
14111446
if both_mergeable {
1412-
existing.merge(v.clone())?;
1447+
existing.deep_merge(v.clone())?;
14131448
} else if *existing != *v {
1414-
// A genuine leaf conflict: the same path holds two different
1415-
// values. (Equal values are tolerated as a no-op, which the
1416-
// shared rule-evaluation path relies on.)
14171449
bail!(
14181450
"value for key `{}` generated multiple times: `{}` and `{}`",
14191451
serde_json::to_string_pretty(&k).map_err(anyhow::Error::msg)?,
@@ -1425,7 +1457,6 @@ impl Value {
14251457
}
14261458
None => {
14271459
map.insert(k.clone(), v.clone());
1428-
// Enforce allocator limit after merging object entries.
14291460
enforce_limit_anyhow()?;
14301461
}
14311462
};
@@ -1436,16 +1467,14 @@ impl Value {
14361467
Ok(())
14371468
}
14381469

1439-
/// Read-only check that merging `other` into `self` via [`Value::merge`] would
1440-
/// not conflict, without mutating `self` or allocating.
1470+
/// Read-only check that [`deep_merge`](Value::deep_merge)-ing `other` into `self` would not
1471+
/// conflict, without mutating or allocating.
14411472
///
1442-
/// This mirrors `merge`'s conflict rule exactly — objects are deep-merged, sets
1443-
/// union (never conflict), equal values are a tolerated no-op, and any other
1444-
/// differing pair is a conflict — but only *reads*. [`Engine::add_data`] runs this
1445-
/// before `merge` so a rejected add is all-or-nothing: a conflict deep in a nested
1446-
/// document cannot leave earlier keys of the same document partially written.
1447-
/// Keeping the check separate preserves `merge`'s in-place (uniquely-owned) fast
1448-
/// path — no candidate copy of the data document is made just to validate it.
1473+
/// Lets [`Engine::add_data`] validate before merging in place. Since a conflict is the only
1474+
/// way the default-build merge can fail and it depends only on the inputs, a passing scan
1475+
/// guarantees the in-place `deep_merge` won't fail — avoiding the alternative of cloning the
1476+
/// whole document into a candidate just to validate. Only overlapping keys are walked, so
1477+
/// disjoint additions are near-free.
14491478
///
14501479
/// [`Engine::add_data`]: crate::Engine::add_data
14511480
#[cfg(not(feature = "allocator-memory-limits"))]
@@ -1455,12 +1484,11 @@ impl Value {
14551484
}
14561485
match (self, other) {
14571486
(Value::Undefined, _) => Ok(()),
1458-
// Set union never conflicts (matches the `(Set, Set)` merge arm).
1487+
// Set union never conflicts.
14591488
(Value::Set(_), Value::Set(_)) => Ok(()),
14601489
(Value::Object(dst), Value::Object(src)) => {
14611490
for (k, sv) in src.iter() {
1462-
// Only overlapping keys can conflict; keys unique to `src` are
1463-
// pure insertions.
1491+
// Only overlapping keys can conflict.
14641492
if let Some(dv) = dst.get(k) {
14651493
let both_mergeable = matches!(
14661494
(dv, sv),

0 commit comments

Comments
 (0)