From 37cfa863eeb6e2053c3df906ea72c55d9f436d6c Mon Sep 17 00:00:00 2001 From: "A.Shakhmatov" Date: Sun, 9 Aug 2026 22:26:30 +0300 Subject: [PATCH] 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. --- src/commands.zig | 508 ++++++++++++++++++++++++++++----- tests/spec/aggregate/README.md | 13 +- 2 files changed, 447 insertions(+), 74 deletions(-) diff --git a/src/commands.zig b/src/commands.zig index 3b058f0..06d9465 100644 --- a/src/commands.zig +++ b/src/commands.zig @@ -107,6 +107,15 @@ pub const ErrorCode = enum(i32) { location_project_mixed = 31254, location_project_unknown_expression = 31325, 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_group_needs_id = 15955, 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 -/// until there is, anything else has to be *refused*. It used to fall through -/// to a zero: `{$avg: "$x"}` answered `0` with `ok: 1`, and so did `$max` and -/// `$push`, and a compound `_id` collapsed every document into one group keyed -/// by the unevaluated expression. A wrong number that reports success is worse -/// than an error, because nobody files it. -const GroupExpr = union(enum) { +/// Compiled once per pipeline and evaluated per document, and that split is +/// what preserves the property M2's refusals bought: a pipeline that cannot be +/// answered is refused before a single document is read, rather than half way +/// through with part of the work already reported. +const Expr = union(enum) { + /// A `$`-prefixed string, holding the path without its sigil. path: []const u8, constant: bson.Value, + /// `{a: , b: }`: 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. -/// -/// `what` names the position for the message -- mongod's own messages name the -/// field, and a refusal that does not say what it refused is only half an -/// improvement on a silent zero. -fn classify_expr(reply: *wire.Reply, v: bson.Value, what: []const u8) !?GroupExpr { - const detail = switch (v) { - .string => |str| { - // "$x" is a path; "x" is the string itself. This is the only place - // that distinction is made now, where it used to be open-coded at - // each use and disagree between them. - if (str.len > 0 and str[0] == '$') return GroupExpr{ .path = str[1..] }; - return GroupExpr{ .constant = v }; - }, - // An operator document is the one shape mongod also refuses, and 168 is - // the code it uses, naming the operator. A compound expression - // (`{a: "$x"}`) mongod would happily evaluate -- so the code is the - // same and the message says what is actually true here instead. - .doc => |d| if (d.len > 0 and d[0].key.len > 0 and d[0].key[0] == '$') - try std.fmt.allocPrint(reply.arena_alloc(), "Unrecognized expression '{s}'", .{d[0].key}) - else - try std.fmt.allocPrint( - reply.arena_alloc(), - "{s} must be a field path or a constant: this server evaluates no expressions", - .{what}, - ), - .array => try std.fmt.allocPrint( - reply.arena_alloc(), - "{s} must be a field path or a constant: this server evaluates no expressions", - .{what}, - ), - else => return GroupExpr{ .constant = v }, +const OpKind = enum { + literal, + add, + subtract, + multiply, + divide, + mod, + eq, + ne, + lt, + lte, + gt, + gte, + cmp, + all_of, + any_of, + not, + cond, + if_null, + switch_, +}; + +const Operator = struct { + kind: OpKind, + args: []const Expr, + /// `$switch` only, and only when it had one. Kept apart from `args`, + /// because "no default" and "a default of null" are different answers -- + /// the first is an error, measured as 40069. + fallback: ?*const Expr = null, +}; + +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; } +/// 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: }}`. 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 /// comes back an int32, anything else a double. The count fast path in /// `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 { key: []const u8, kind: AccKind, - arg: GroupExpr, + arg: Expr, }; /// What one accumulator has seen of one group so far. @@ -2824,7 +3204,7 @@ fn run_group( ); 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 // 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 // expression classifier -- an empty document is exactly what that // refuses. - const arg: GroupExpr = if (kind == .count) + const arg: Expr = if (kind == .count) .{ .constant = .null } else blk: { 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 }); } @@ -2897,10 +3277,11 @@ fn run_group( defer walk_arena.deinit(); var i: usize = 0; while (i < src.len()) : (i += 1) { - const id_value: bson.Value = switch (id_class) { - .path => |path| (try stream_path(walk_arena.allocator(), coll, src, i, path)) orelse .null, - .constant => |v| v, - }; + const ec: EvalCtx = .{ .arena = walk_arena.allocator(), .coll = coll, .src = src, .i = i }; + const id_value: bson.Value = (eval_expr(ec, id_class) catch |err| { + try report_eval_error(reply, err); + return null; + }) orelse .null; id_key_buf.clearRetainingCapacity(); 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 // these treat differently from a present null: `$push` skips it // where it would push an explicit null, and `$min` ignores it. - const found: ?bson.Value = switch (acc.arg) { - .path => |path| try stream_path(walk_arena.allocator(), coll, src, i, path), - .constant => |c| c, + const found: ?bson.Value = eval_expr(ec, acc.arg) catch |err| { + try report_eval_error(reply, err); + return null; }; switch (acc.kind) { .count => unreachable, @@ -4788,31 +5169,24 @@ test "$group refuses what it cannot compute instead of answering zero" { .group = &.{.{ .key = "v", .value = sum_one }}, .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 = &.{ - .{ .key = "_id", .value = .{ .doc = &.{.{ .key = "k", .value = .{ .string = "$k" } }} } }, + .{ .key = "_id", .value = .{ .doc = &.{.{ .key = "$bogusExpr", .value = path_x }} } }, .{ .key = "v", .value = sum_one }, }, .code = 168, }, .{ - .name = "an operator expression in _id", + .name = "an operator given the wrong number of operands", .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 }, }, - .code = 168, - }, - .{ - .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, + .code = 16020, }, }; diff --git a/tests/spec/aggregate/README.md b/tests/spec/aggregate/README.md index 06b4117..6188800 100644 --- a/tests/spec/aggregate/README.md +++ b/tests/spec/aggregate/README.md @@ -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: