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

@@ -107,6 +107,15 @@ pub const ErrorCode = enum(i32) {
location_project_mixed = 31254, location_project_mixed = 31254,
location_project_unknown_expression = 31325, location_project_unknown_expression = 31325,
location_write_stage_not_last = 40601, location_write_stage_not_last = 40601,
// Expression codes, measured with the corpus recorder against mongod
// 8.3.7. Note 15983 rather than $group's 40238 for "two operators in one
// document": mongod distinguishes an expression from an accumulator there,
// and a client switching on the code would notice if we did not.
location_two_expression_operators = 15983,
location_wrong_operand_count = 16020,
location_switch_no_default = 40069,
location_divide_by_zero = 4848401,
location_non_numeric_arithmetic = 7157723,
location_unknown_group_operator = 15952, location_unknown_group_operator = 15952,
location_group_needs_id = 15955, location_group_needs_id = 15955,
location_accumulator_not_object = 40234, location_accumulator_not_object = 40234,
@@ -2699,56 +2708,427 @@ fn stream_path(
}; };
} }
/// The whole vocabulary this engine can evaluate: a field path, or a constant. /// A compiled aggregation expression.
/// ///
/// There is no expression evaluator (PLAN amendment A6 puts one in M2.5), and /// Compiled once per pipeline and evaluated per document, and that split is
/// until there is, anything else has to be *refused*. It used to fall through /// what preserves the property M2's refusals bought: a pipeline that cannot be
/// to a zero: `{$avg: "$x"}` answered `0` with `ok: 1`, and so did `$max` and /// answered is refused before a single document is read, rather than half way
/// `$push`, and a compound `_id` collapsed every document into one group keyed /// through with part of the work already reported.
/// by the unevaluated expression. A wrong number that reports success is worse const Expr = union(enum) {
/// than an error, because nobody files it. /// A `$`-prefixed string, holding the path without its sigil.
const GroupExpr = union(enum) {
path: []const u8, path: []const u8,
constant: bson.Value, constant: bson.Value,
/// `{a: <expr>, b: <expr>}`: a document whose values are expressions. This
/// is what a compound `$group` `_id` is, and it used to collapse every
/// document into one group keyed by the unevaluated document.
fields: []const Field,
array: []const Expr,
op: Operator,
const Field = struct { key: []const u8, value: Expr };
}; };
/// Classify `v`, or answer the client and return null. const OpKind = enum {
/// literal,
/// `what` names the position for the message -- mongod's own messages name the add,
/// field, and a refusal that does not say what it refused is only half an subtract,
/// improvement on a silent zero. multiply,
fn classify_expr(reply: *wire.Reply, v: bson.Value, what: []const u8) !?GroupExpr { divide,
const detail = switch (v) { mod,
.string => |str| { eq,
// "$x" is a path; "x" is the string itself. This is the only place ne,
// that distinction is made now, where it used to be open-coded at lt,
// each use and disagree between them. lte,
if (str.len > 0 and str[0] == '$') return GroupExpr{ .path = str[1..] }; gt,
return GroupExpr{ .constant = v }; gte,
}, cmp,
// An operator document is the one shape mongod also refuses, and 168 is all_of,
// the code it uses, naming the operator. A compound expression any_of,
// (`{a: "$x"}`) mongod would happily evaluate -- so the code is the not,
// same and the message says what is actually true here instead. cond,
.doc => |d| if (d.len > 0 and d[0].key.len > 0 and d[0].key[0] == '$') if_null,
try std.fmt.allocPrint(reply.arena_alloc(), "Unrecognized expression '{s}'", .{d[0].key}) switch_,
else };
try std.fmt.allocPrint(
reply.arena_alloc(), const Operator = struct {
"{s} must be a field path or a constant: this server evaluates no expressions", kind: OpKind,
.{what}, args: []const Expr,
), /// `$switch` only, and only when it had one. Kept apart from `args`,
.array => try std.fmt.allocPrint( /// because "no default" and "a default of null" are different answers --
reply.arena_alloc(), /// the first is an error, measured as 40069.
"{s} must be a field path or a constant: this server evaluates no expressions", fallback: ?*const Expr = null,
.{what}, };
),
else => return GroupExpr{ .constant = v }, fn op_kind(name: []const u8) ?OpKind {
const table = .{
.{ "$literal", OpKind.literal },
.{ "$add", OpKind.add },
.{ "$subtract", OpKind.subtract },
.{ "$multiply", OpKind.multiply },
.{ "$divide", OpKind.divide },
.{ "$mod", OpKind.mod },
.{ "$eq", OpKind.eq },
.{ "$ne", OpKind.ne },
.{ "$lt", OpKind.lt },
.{ "$lte", OpKind.lte },
.{ "$gt", OpKind.gt },
.{ "$gte", OpKind.gte },
.{ "$cmp", OpKind.cmp },
.{ "$and", OpKind.all_of },
.{ "$or", OpKind.any_of },
.{ "$not", OpKind.not },
.{ "$cond", OpKind.cond },
.{ "$ifNull", OpKind.if_null },
.{ "$switch", OpKind.switch_ },
}; };
try reply.put_error(@intFromEnum(ErrorCode.invalid_pipeline_operator), "InvalidPipelineOperator", detail); inline for (table) |e| {
if (std.mem.eql(u8, name, e[0])) return e[1];
}
return null; return null;
} }
/// Operands an operator takes, or null when it is variadic. mongod checks this
/// at parse time and answers 16020, which is why this is a compile-time table
/// rather than a runtime length test.
fn op_arity(kind: OpKind) ?usize {
return switch (kind) {
.literal, .not => 1,
.subtract, .divide, .mod, .cmp => 2,
.cond => 3,
else => null,
};
}
/// Compilation allocates and reports; it reads no documents, so nothing else
/// can go wrong. Written out rather than inferred because `compile_expr` and
/// `compile_operator` call each other, and Zig cannot infer a cycle.
const CompileError = std.mem.Allocator.Error;
/// Compile `v` into an expression, or answer the client and return null.
///
/// `what` names the position, because a refusal that does not say what it
/// refused is only half an improvement on a silent zero.
fn compile_expr(reply: *wire.Reply, arena: std.mem.Allocator, v: bson.Value, what: []const u8) CompileError!?Expr {
switch (v) {
.string => |str| {
// "$x" is a path; "x" is the string itself. The one place that
// distinction is made, where it used to be open-coded per use.
if (str.len > 0 and str[0] == '$') return Expr{ .path = str[1..] };
return Expr{ .constant = v };
},
.array => |items| {
const out = try arena.alloc(Expr, items.len);
for (items, 0..) |item, i| {
out[i] = (try compile_expr(reply, arena, item, what)) orelse return null;
}
return Expr{ .array = out };
},
.doc => |d| {
const leads_with_op = d.len > 0 and d[0].key.len > 0 and d[0].key[0] == '$';
if (!leads_with_op) {
// A compound expression: every value is itself an expression.
const out = try arena.alloc(Expr.Field, d.len);
for (d, 0..) |p, i| {
const sub = (try compile_expr(reply, arena, p.value, what)) orelse return null;
out[i] = .{ .key = p.key, .value = sub };
}
return Expr{ .fields = out };
}
if (d.len != 1) {
// 15983, and deliberately not `$group`'s 40238: mongod uses a
// different code for an expression than for an accumulator, and
// a client that switches on the code would notice.
const detail = try std.fmt.allocPrint(
reply.arena_alloc(),
"an expression specification must contain exactly one field, the name of the expression",
.{},
);
try reply.put_error(@intFromEnum(ErrorCode.location_two_expression_operators), "Location15983", detail);
return null;
}
const kind = op_kind(d[0].key) orelse {
const detail = try std.fmt.allocPrint(
reply.arena_alloc(),
"Unrecognized expression '{s}'",
.{d[0].key},
);
try reply.put_error(@intFromEnum(ErrorCode.invalid_pipeline_operator), "InvalidPipelineOperator", detail);
return null;
};
return compile_operator(reply, arena, kind, d[0].key, d[0].value, what);
},
else => return Expr{ .constant = v },
}
}
fn compile_operator(
reply: *wire.Reply,
arena: std.mem.Allocator,
kind: OpKind,
name: []const u8,
spec: bson.Value,
what: []const u8,
) CompileError!?Expr {
// `$literal` is the one operator whose argument is *not* an expression --
// that is the whole of what it does.
if (kind == .literal) {
const one = try arena.alloc(Expr, 1);
one[0] = .{ .constant = spec };
return Expr{ .op = .{ .kind = kind, .args = one } };
}
if (kind == .switch_) return compile_switch(reply, arena, spec, what);
// `$cond` has a document form as well as its three-element array.
if (kind == .cond and spec == .doc and spec.doc.len > 0 and spec.doc[0].key[0] != '$') {
const args = try arena.alloc(Expr, 3);
const names = [_][]const u8{ "if", "then", "else" };
for (names, 0..) |field, i| {
const sub = bson.get_pair(spec.doc, field) orelse {
const detail = try std.fmt.allocPrint(reply.arena_alloc(), "Missing '{s}' parameter to $cond", .{field});
try reply.put_error(@intFromEnum(ErrorCode.location_wrong_operand_count), "Location16020", detail);
return null;
};
args[i] = (try compile_expr(reply, arena, sub, what)) orelse return null;
}
return Expr{ .op = .{ .kind = kind, .args = args } };
}
// Everything else takes its operands as an array, or as a bare value when
// there is one of them -- `{$not: "$f"}` is as legal as `{$not: ["$f"]}`.
var args: []const Expr = undefined;
if (spec == .array) {
const out = try arena.alloc(Expr, spec.array.len);
for (spec.array, 0..) |item, i| {
out[i] = (try compile_expr(reply, arena, item, what)) orelse return null;
}
args = out;
} else {
const one = try arena.alloc(Expr, 1);
one[0] = (try compile_expr(reply, arena, spec, what)) orelse return null;
args = one;
}
if (op_arity(kind)) |want| {
if (args.len != want) {
const detail = try std.fmt.allocPrint(
reply.arena_alloc(),
"Expression {s} takes exactly {d} arguments. {d} were passed in.",
.{ name, want, args.len },
);
try reply.put_error(@intFromEnum(ErrorCode.location_wrong_operand_count), "Location16020", detail);
return null;
}
}
return Expr{ .op = .{ .kind = kind, .args = args } };
}
/// `{$switch: {branches: [{case, then}, ...], default: <expr>}}`. The branches
/// flatten into `args` as case, then, case, then; the default stays apart, so
/// that having none is distinguishable from having one that is null.
fn compile_switch(reply: *wire.Reply, arena: std.mem.Allocator, spec: bson.Value, what: []const u8) CompileError!?Expr {
const d = doc_arg(spec) orelse {
try bad_value(reply, "$switch requires an object");
return null;
};
const branches = switch (bson.get_pair(d, "branches") orelse bson.Value.null) {
.array => |a| a,
else => {
try bad_value(reply, "$switch requires an array of branches");
return null;
},
};
const args = try arena.alloc(Expr, branches.len * 2);
for (branches, 0..) |b, i| {
const bd = doc_arg(b) orelse {
try bad_value(reply, "$switch branches must be objects");
return null;
};
const case_v = bson.get_pair(bd, "case") orelse {
try bad_value(reply, "$switch branch requires a case");
return null;
};
const then_v = bson.get_pair(bd, "then") orelse {
try bad_value(reply, "$switch branch requires a then");
return null;
};
args[i * 2] = (try compile_expr(reply, arena, case_v, what)) orelse return null;
args[i * 2 + 1] = (try compile_expr(reply, arena, then_v, what)) orelse return null;
}
var fallback: ?*const Expr = null;
if (bson.get_pair(d, "default")) |dv| {
const boxed = try arena.create(Expr);
boxed.* = (try compile_expr(reply, arena, dv, what)) orelse return null;
fallback = boxed;
}
return Expr{ .op = .{ .kind = .switch_, .args = args, .fallback = fallback } };
}
/// A failure only a document can produce, so it cannot be caught at compile
/// time. Both codes measured against mongod.
const EvalError = error{ DivideByZero, SwitchNoDefault, NonNumericArithmetic } ||
std.mem.Allocator.Error || error{ EndOfStream, Overflow, InvalidBson };
/// Turn a failure only a document could produce into the reply mongod gives.
fn report_eval_error(reply: *wire.Reply, err: EvalError) !void {
switch (err) {
error.DivideByZero => try reply.put_error(
@intFromEnum(ErrorCode.location_divide_by_zero),
"Location4848401",
"can't $divide by zero",
),
error.SwitchNoDefault => try reply.put_error(
@intFromEnum(ErrorCode.location_switch_no_default),
"Location40069",
"$switch could not find a matching branch for an input, and no default was specified.",
),
error.NonNumericArithmetic => try reply.put_error(
@intFromEnum(ErrorCode.location_non_numeric_arithmetic),
"Location7157723",
"only numbers are allowed in an $add or $subtract expression",
),
else => return err,
}
}
/// Where an expression reads its document from.
const EvalCtx = struct {
arena: std.mem.Allocator,
coll: *const Collection,
src: Stream,
i: usize,
};
/// Evaluate `e` against one document. `null` is *absent*, which several
/// operators distinguish from a present BSON null -- `$ifNull` treats them
/// alike, `$push` does not.
fn eval_expr(ec: EvalCtx, e: Expr) EvalError!?bson.Value {
switch (e) {
.path => |path| return stream_path(ec.arena, ec.coll, ec.src, ec.i, path),
.constant => |v| return v,
.fields => |fs| {
const pairs = try ec.arena.alloc(bson.Pair, fs.len);
var n: usize = 0;
for (fs) |f| {
// A field whose expression resolves to nothing is left out,
// which is how a compound `_id` drops a missing path.
const v = (try eval_expr(ec, f.value)) orelse continue;
pairs[n] = .{ .key = f.key, .value = v };
n += 1;
}
return bson.Value{ .doc = pairs[0..n] };
},
.array => |items| {
const out = try ec.arena.alloc(bson.Value, items.len);
for (items, 0..) |item, i| out[i] = (try eval_expr(ec, item)) orelse .null;
return bson.Value{ .array = out };
},
.op => |o| return eval_operator(ec, o),
}
}
fn eval_operator(ec: EvalCtx, o: Operator) EvalError!?bson.Value {
switch (o.kind) {
.literal => return o.args[0].constant,
.add, .multiply, .subtract, .divide, .mod => return eval_arithmetic(ec, o),
.eq, .ne, .lt, .lte, .gt, .gte, .cmp => {
// Absent compares as null, which is what makes
// `{$eq: ["$missing", null]}` true.
const a = (try eval_expr(ec, o.args[0])) orelse .null;
const b = (try eval_expr(ec, o.args[1])) orelse .null;
const ord = bson.compare(a, b);
if (o.kind == .cmp) return bson.Value{ .int32 = switch (ord) {
.lt => -1,
.eq => 0,
.gt => 1,
} };
return bson.Value{ .bool = switch (o.kind) {
.eq => ord == .eq,
.ne => ord != .eq,
.lt => ord == .lt,
.lte => ord != .gt,
.gt => ord == .gt,
.gte => ord != .lt,
else => unreachable,
} };
},
.all_of, .any_of => {
const want = o.kind == .any_of;
for (o.args) |arg| {
if (expr_truthy(try eval_expr(ec, arg)) == want) return bson.Value{ .bool = want };
}
return bson.Value{ .bool = !want };
},
.not => return bson.Value{ .bool = !expr_truthy(try eval_expr(ec, o.args[0])) },
.cond => {
const take: usize = if (expr_truthy(try eval_expr(ec, o.args[0]))) 1 else 2;
return eval_expr(ec, o.args[take]);
},
.if_null => {
// Every operand but the last is a candidate; the last is the
// fallback, and is returned whether or not it is itself null.
for (o.args[0 .. o.args.len - 1]) |arg| {
const v = try eval_expr(ec, arg);
if (v) |present| {
if (present != .null) return present;
}
}
return eval_expr(ec, o.args[o.args.len - 1]);
},
.switch_ => {
var i: usize = 0;
while (i < o.args.len) : (i += 2) {
if (expr_truthy(try eval_expr(ec, o.args[i]))) return eval_expr(ec, o.args[i + 1]);
}
const fallback = o.fallback orelse return error.SwitchNoDefault;
return eval_expr(ec, fallback.*);
},
}
}
/// MongoDB's truthiness: false, null, absent and any numeric zero are false,
/// and everything else -- including a negative number and an empty string --
/// is true.
fn expr_truthy(v: ?bson.Value) bool {
return switch (v orelse return false) {
.bool => |b| b,
.null => false,
.int32 => |n| n != 0,
.int64 => |n| n != 0,
.double => |n| n != 0,
else => true,
};
}
fn eval_arithmetic(ec: EvalCtx, o: Operator) EvalError!?bson.Value {
var acc: f64 = if (o.kind == .multiply) 1 else 0;
for (o.args, 0..) |arg, i| {
const v = try eval_expr(ec, arg);
// Absent or null makes the whole expression null -- not an error, and
// not a zero. Measured: `{$add: ["$missing", 1]}` is null.
const present = v orelse return bson.Value.null;
const x: f64 = switch (present) {
.int32 => |n| @floatFromInt(n),
.int64 => |n| @floatFromInt(n),
.double => |n| n,
.null => return bson.Value.null,
else => return error.NonNumericArithmetic,
};
switch (o.kind) {
.add => acc += x,
.multiply => acc *= x,
.subtract => acc = if (i == 0) x else acc - x,
.divide, .mod => {
if (i == 0) {
acc = x;
} else {
if (x == 0) return error.DivideByZero;
acc = if (o.kind == .divide) acc / x else @rem(acc, x);
}
},
else => unreachable,
}
}
return numeric_value(acc);
}
/// A running total as MongoDB reports it: an integral value inside int32 range /// A running total as MongoDB reports it: an integral value inside int32 range
/// comes back an int32, anything else a double. The count fast path in /// comes back an int32, anything else a double. The count fast path in
/// `cmd_aggregate` mirrors this exactly, and a divergence between them would /// `cmd_aggregate` mirrors this exactly, and a divergence between them would
@@ -2784,7 +3164,7 @@ fn acc_kind(name: []const u8) ?AccKind {
const Accumulator = struct { const Accumulator = struct {
key: []const u8, key: []const u8,
kind: AccKind, kind: AccKind,
arg: GroupExpr, arg: Expr,
}; };
/// What one accumulator has seen of one group so far. /// What one accumulator has seen of one group so far.
@@ -2824,7 +3204,7 @@ fn run_group(
); );
return null; return null;
}; };
const id_class = (try classify_expr(reply, id_expr, "the _id of a $group")) orelse return null; const id_class = (try compile_expr(reply, arena, id_expr, "the _id of a $group")) orelse return null;
// Every accumulator is validated before a single document is read, so a // Every accumulator is validated before a single document is read, so a
// pipeline that cannot be answered is refused rather than half-answered. // pipeline that cannot be answered is refused rather than half-answered.
@@ -2868,11 +3248,11 @@ fn run_group(
// `$count` takes `{}` and nothing else, so it never reaches the // `$count` takes `{}` and nothing else, so it never reaches the
// expression classifier -- an empty document is exactly what that // expression classifier -- an empty document is exactly what that
// refuses. // refuses.
const arg: GroupExpr = if (kind == .count) const arg: Expr = if (kind == .count)
.{ .constant = .null } .{ .constant = .null }
else blk: { else blk: {
const what = try std.fmt.allocPrint(arena, "the argument of '{s}'", .{p.key}); const what = try std.fmt.allocPrint(arena, "the argument of '{s}'", .{p.key});
break :blk (try classify_expr(reply, spec[0].value, what)) orelse return null; break :blk (try compile_expr(reply, arena, spec[0].value, what)) orelse return null;
}; };
try accs.append(arena, .{ .key = p.key, .kind = kind, .arg = arg }); try accs.append(arena, .{ .key = p.key, .kind = kind, .arg = arg });
} }
@@ -2897,10 +3277,11 @@ fn run_group(
defer walk_arena.deinit(); defer walk_arena.deinit();
var i: usize = 0; var i: usize = 0;
while (i < src.len()) : (i += 1) { while (i < src.len()) : (i += 1) {
const id_value: bson.Value = switch (id_class) { const ec: EvalCtx = .{ .arena = walk_arena.allocator(), .coll = coll, .src = src, .i = i };
.path => |path| (try stream_path(walk_arena.allocator(), coll, src, i, path)) orelse .null, const id_value: bson.Value = (eval_expr(ec, id_class) catch |err| {
.constant => |v| v, try report_eval_error(reply, err);
}; return null;
}) orelse .null;
id_key_buf.clearRetainingCapacity(); id_key_buf.clearRetainingCapacity();
try bson.write_value(id_value, ctx.gpa, &id_key_buf); try bson.write_value(id_value, ctx.gpa, &id_key_buf);
@@ -2924,9 +3305,9 @@ fn run_group(
// A path that resolves to nothing is *absent*, which several of // A path that resolves to nothing is *absent*, which several of
// these treat differently from a present null: `$push` skips it // these treat differently from a present null: `$push` skips it
// where it would push an explicit null, and `$min` ignores it. // where it would push an explicit null, and `$min` ignores it.
const found: ?bson.Value = switch (acc.arg) { const found: ?bson.Value = eval_expr(ec, acc.arg) catch |err| {
.path => |path| try stream_path(walk_arena.allocator(), coll, src, i, path), try report_eval_error(reply, err);
.constant => |c| c, return null;
}; };
switch (acc.kind) { switch (acc.kind) {
.count => unreachable, .count => unreachable,
@@ -4788,31 +5169,24 @@ test "$group refuses what it cannot compute instead of answering zero" {
.group = &.{.{ .key = "v", .value = sum_one }}, .group = &.{.{ .key = "v", .value = sum_one }},
.code = 15955, .code = 15955,
}, },
// A compound `_id`, `$literal` and a `$multiply` argument stood here
// until the expression evaluator landed. What is refused is still what
// is missing, and these are the two that remain missing.
.{ .{
.name = "a compound _id", .name = "an unknown expression operator",
.group = &.{ .group = &.{
.{ .key = "_id", .value = .{ .doc = &.{.{ .key = "k", .value = .{ .string = "$k" } }} } }, .{ .key = "_id", .value = .{ .doc = &.{.{ .key = "$bogusExpr", .value = path_x }} } },
.{ .key = "v", .value = sum_one }, .{ .key = "v", .value = sum_one },
}, },
.code = 168, .code = 168,
}, },
.{ .{
.name = "an operator expression in _id", .name = "an operator given the wrong number of operands",
.group = &.{ .group = &.{
.{ .key = "_id", .value = .{ .doc = &.{.{ .key = "$literal", .value = .{ .int32 = 1 } }} } }, .{ .key = "_id", .value = .{ .doc = &.{.{ .key = "$subtract", .value = .{ .array = &.{path_x} } }} } },
.{ .key = "v", .value = sum_one }, .{ .key = "v", .value = sum_one },
}, },
.code = 168, .code = 16020,
},
.{
.name = "an expression argument to $sum",
.group = &.{
.{ .key = "_id", .value = .null },
.{ .key = "v", .value = .{ .doc = &.{.{ .key = "$sum", .value = .{ .doc = &.{
.{ .key = "$multiply", .value = .{ .array = &.{ path_x, .{ .int32 = 2 } } } },
} } }} } },
},
.code = 168,
}, },
}; };

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