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:
508
src/commands.zig
508
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: <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.
|
||||
///
|
||||
/// `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_,
|
||||
};
|
||||
try reply.put_error(@intFromEnum(ErrorCode.invalid_pipeline_operator), "InvalidPipelineOperator", detail);
|
||||
|
||||
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_ },
|
||||
};
|
||||
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: <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
|
||||
/// 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,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
Reference in New Issue
Block a user