commands: the aggregation expression evaluator

The whole corpus is green: 46 pass, 0 fail, byte-identical to mongod 8.3.7 on
every case including all 27 expressions and the compound `_id` that was the
last accumulator failure.

Expressions are *compiled once per pipeline and evaluated per document*, and
that split is the point rather than an optimisation: it keeps the property M2's
refusals bought, which is that a pipeline that cannot be answered is refused
before a single document is read instead of half way through with part of the
work already reported. `Expr` is the compiled tree, `compile_expr` reports,
`eval_expr` cannot.

Nineteen operators: `$literal`, the five arithmetic ones, seven comparisons,
`$and`/`$or`/`$not`, `$cond` in both its forms, `$ifNull` and `$switch`. Plus
the two shapes that are not operators at all -- a compound document, which is
what a `$group` `_id` usually is, and an array.

Everything the corpus recorded, and none of it guessable:

  - absent and a present null are *different* internally, because `$ifNull`
    treats them alike and `$push` does not. Hence `?bson.Value` throughout,
    where the obvious shortcut is to fold absent into `.null` at the boundary
    and lose the distinction for good.
  - arithmetic over absent or null is `null` -- not an error, not zero -- and
    over a string is an error, 7157723.
  - `$divide` by zero is 4848401, `$switch` with no branch and no default is
    40069, and both are failures only a document can produce, so `EvalError`
    exists and `report_eval_error` maps it.
  - two operators in one expression document is 15983 and *not* `$group`'s
    40238: mongod distinguishes an expression from an accumulator there.
  - truthiness is MongoDB's, so `-5` is true and `0.0` is false.
  - `$mod` follows the dividend's sign, so -5 mod 4 is -1.
  - `$not` takes a bare argument as readily as a one-element array.

`compile_expr` and `compile_operator` call each other, so their error set is
written out rather than inferred -- Zig cannot infer a cycle, and the failure
mode is a "dependency loop" message that says nothing about expressions.

Three cases left the Tier 0 refusal test, because a compound `_id`, `$literal`
and a `$multiply` argument all work now. What is refused should be what is
missing, so an unknown operator and a wrong operand count took their place.

191/191 unit tests in ReleaseFast and ReleaseSafe, 83/83 fuzz, e2e 49, e2e2
concurrent, e2e3 16, e2e4 17, e2e6 72, e2e7 86, crud corpus unchanged at
201/90/196.
This commit is contained in:
A.Shakhmatov
2026-08-09 22:26:30 +03:00
parent 1c72aa8938
commit 37cfa863ee
2 changed files with 447 additions and 74 deletions

View File

@@ -62,8 +62,8 @@ Recorded against mongod 8.3.7. At the M2 tip it read 9 pass / 10 fail; with the
accumulators in:
```
group-accumulators.json 18 pass 1 fail 0 skip
expressions.json 1 pass 26 fail 0 skip
group-accumulators.json 19 pass 0 fail 0 skip
expressions.json 27 pass 0 fail 0 skip
```
`group-accumulators` found its first real disagreement on the way to 18:
@@ -71,11 +71,10 @@ expressions.json 1 pass 26 fail 0 skip
that counted documents rather than numbers would have passed every test
anybody would think to write by hand.
`expressions.json` is the next tier's spec, recorded and not yet implemented --
its one pass is the unknown-operator refusal M2 already answers correctly.
Expressions are exercised through `$group`, because `_id` and the accumulator
arguments are the only expression positions that exist until `$addFields` and
`$project`'s computed fields land.
`expressions.json` was recorded before the evaluator was written and read
1 pass / 26 fail against it; it is green now. Expressions are exercised through
`$group`, because `_id` and the accumulator arguments are the only expression
positions that exist until `$addFields` and `$project`'s computed fields land.
What recording it settled, none of which is guessable: