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_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,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user