Files
MultiforaDB/src/query.zig
A.Shakhmatov 235ae19e30 query: a missing field is null to equality, and to nothing else
`find({a: null})` has to match a document with no `a` at all, as well as
one holding an explicit null. This server matched only the explicit one --
with or without an index -- so `{a: null}` returned one row where mongod
returns two, and `{"a.b": null}` returned none where mongod returns three.

Found by tests/spec/indexes/hashed.json, which is the last of the four
recorded corpora and the first test in this repository to ask the
question. The pinned crud+aggregate suite does not: the scorecard is
unchanged at 228/63/196 across this commit.

Two layers already believed this and only the matcher did not.
`index.build_entries` stores a missing field as null under a non-sparse
index, and `evaluate_index`'s sparse guard exists specifically to stop
this query reading an index that skipped those documents -- a guard that
was defending a behaviour that did not exist. So the fix makes three
layers agree rather than introducing a rule.

The substitution is deliberately narrow, and applies to `$eq`/`$in` and
their negations `$ne`/`$nin` only. A missing field is *not* null to
anything else: `{a: {$lt: 5}}` does not match it even though null sorts
below 5, `{a: {$exists: false}}` still has to see that there is nothing
there, and `{a: {$type: "null"}}` stays false. Collecting a null candidate
instead of substituting one would have flipped all three.

Measured against mongod 8.3.7 over a document set covering missing,
explicit null, a value, an empty array and a subdocument, with and without
an index on the path. The equality family now agrees in all four
combinations. Five neighbouring answers still differ and are none of them
touched by this commit -- four trace to one root cause, comparison
operators not being type-bracketed, and one to `{a: []}` traversed by a
dotted path. Both are recorded in PLAN §6.
2026-08-11 00:02:16 +03:00

1798 lines
70 KiB
Zig

//! Query engine: filter matching (MongoDB query operators), sorting by
//! canonical BSON order, and projections. Includes a small backtracking
//! regex engine for $regex.
const std = @import("std");
const bson = @import("bson.zig");
// ---------------------------------------------------------------------------
// Filter matching
// ---------------------------------------------------------------------------
/// The byte matcher operates on stored (canonical, validated) bytes, so an
/// InvalidBson from the walker means a storage bug rather than hostile
/// input — but it must still be a possible error, not a panic.
pub const QueryError = error{ OutOfMemory, InvalidBson };
pub fn matches(
gpa: std.mem.Allocator,
filter: *const bson.Document,
doc: *const bson.Document,
) QueryError!bool {
for (filter.pairs) |p| {
if (p.key.len > 0 and p.key[0] == '$') {
if (!try match_top_level(gpa, p.key, p.value, doc)) return false;
} else {
if (!try field_matches(gpa, p.key, p.value, doc)) return false;
}
}
return true;
}
fn match_top_level(
gpa: std.mem.Allocator,
op: []const u8,
value: bson.Value,
doc: *const bson.Document,
) QueryError!bool {
if (std.mem.eql(u8, op, "$and") or std.mem.eql(u8, op, "$or")) {
const want_and = std.mem.eql(u8, op, "$and");
const filters = switch (value) {
.array => |arr| arr,
else => return false,
};
for (filters) |item| {
const f = switch (item) {
.doc => |pairs| pairs,
else => return false,
};
const matched = try matches(gpa, &.{ .arena = undefined, .pairs = f }, doc);
if (want_and and !matched) return false;
if (!want_and and matched) return true;
}
return want_and;
}
if (std.mem.eql(u8, op, "$nor")) {
const filters = switch (value) {
.array => |arr| arr,
else => return false,
};
for (filters) |item| {
const f = switch (item) {
.doc => |pairs| pairs,
else => return false,
};
if (try matches(gpa, &.{ .arena = undefined, .pairs = f }, doc)) return false;
}
return true;
}
return false;
}
/// True when every key is a `$`-prefixed operator. The one definition of
/// what an operator document looks like — filters, $elemMatch, $pull and
/// upsert-document construction all ask this question.
pub fn all_operator_keys(pairs: []const bson.Pair) bool {
if (pairs.len == 0) return false;
for (pairs) |p| {
if (p.key.len == 0 or p.key[0] != '$') return false;
}
return true;
}
/// The operator pairs of a `{$op: ...}` value, or null if it is not one. An
/// empty document counts as an (vacuously satisfied) operator document.
fn is_operator_doc(value: bson.Value) ?[]const bson.Pair {
const pairs = switch (value) {
.doc => |p| p,
else => return null,
};
if (pairs.len > 0 and !all_operator_keys(pairs)) return null;
return pairs;
}
/// Values a path yields before spilling to the heap. A document almost
/// always contributes exactly one value per field; arrays make it a
/// handful. Collecting those on the stack removes an allocate/free pair per
/// filter field per candidate document, which is the dominant cost of a
/// collection scan.
const inline_candidates = 8;
fn field_matches(
gpa: std.mem.Allocator,
path: []const u8,
expected: bson.Value,
doc: *const bson.Document,
) QueryError!bool {
var stack_fallback = std.heap.stackFallback(inline_candidates * @sizeOf(bson.Value), gpa);
const alloc = stack_fallback.get();
var candidates: std.ArrayListUnmanaged(bson.Value) = .empty;
defer candidates.deinit(alloc);
try collect_values(alloc, doc.pairs, path, &candidates, 0);
try expand_arrays(alloc, &candidates);
return apply_expected(gpa, expected, candidates.items);
}
/// The byte counterpart of field_matches: collects values by walking the
/// canonical BSON element stream of a stored document, skipping by length
/// any field the filter does not name.
fn field_matches_bytes(
gpa: std.mem.Allocator,
path: []const u8,
expected: bson.Value,
bytes: []const u8,
) QueryError!bool {
// An arena, not a stack fallback: the byte walker materializes nested
// doc/array values (whole-array equality, embedded docs) into the
// allocator it is given, and those must be freed with it.
var arena = std.heap.ArenaAllocator.init(gpa);
defer arena.deinit();
const alloc = arena.allocator();
var candidates: std.ArrayListUnmanaged(bson.Value) = .empty;
try collect_values_bytes(alloc, bytes, path, &candidates, 0);
try expand_arrays(alloc, &candidates);
return apply_expected(gpa, expected, candidates.items);
}
/// MongoDB applies queries to array elements as well as the array itself.
/// Index the snapshot length, re-reading items each iteration: appending
/// may reallocate the buffer, which would invalidate a captured slice.
fn expand_arrays(
alloc: std.mem.Allocator,
candidates: *std.ArrayListUnmanaged(bson.Value),
) QueryError!void {
const direct_count = candidates.items.len;
var i: usize = 0;
while (i < direct_count) : (i += 1) {
const a = candidates.items[i];
if (a == .array) {
for (a.array) |elem| try candidates.append(alloc, elem);
}
}
}
/// The operator/equality half of field matching, shared by the tree and
/// byte collectors.
fn apply_expected(
gpa: std.mem.Allocator,
expected: bson.Value,
candidates: []const bson.Value,
) QueryError!bool {
if (is_operator_doc(expected)) |pairs| {
// $options modifies $regex wherever it appears in the document, so
// it has to be known before any operator runs.
var options: []const u8 = "";
for (pairs) |p| {
if (parse_op(p.key) == .options and p.value == .string) options = p.value.string;
}
for (pairs) |p| {
const op = parse_op(p.key);
if (op == .options) continue;
if (!try match_operator(gpa, op, p.value, candidates, options)) return false;
}
return true;
}
// Bare BSON regex value: {field: /re/} behaves like {$regex: "re"}.
if (expected == .regex) {
for (candidates) |a| {
if (a == .string and regex_match(expected.regex.pattern, expected.regex.options, a.string)) return true;
}
return false;
}
// Bare equality — matches if any candidate equals the expected value.
for (equality_candidates(candidates)) |actual| {
if (bson.compare(actual, expected) == .eq) return true;
}
return false;
}
/// A path that yields nothing is null to the equality family, and to it
/// alone.
///
/// `{a: null}` matches a document with no `a` at all, as well as one holding
/// an explicit null. `{a: {$lt: 5}}` does not, even though null sorts below
/// 5; `{a: {$exists: false}}` has to keep seeing that there is nothing there;
/// `{a: {$type: "null"}}` stays false. So this substitutes a single null
/// rather than collecting one, and only where equality can see it.
///
/// The other two layers have always agreed with this and only the matcher
/// did not: `index.build_entries` stores a missing field as null under a
/// non-sparse index, and the planner's sparse guard exists to stop exactly
/// this query reading an index that skipped those documents. Without this the
/// guard was defending a behaviour that did not exist.
const missing_as_null = [_]bson.Value{.null};
fn equality_candidates(candidates: []const bson.Value) []const bson.Value {
return if (candidates.len == 0) &missing_as_null else candidates;
}
/// Whether `op` compares for equality, which is what decides that a missing
/// field is null. `$ne` and `$nin` are in it because they are the negations:
/// `{a: {$ne: null}}` has to *exclude* a document with no `a`.
fn equality_family(op: Op) bool {
return switch (op) {
.eq, .ne, .in, .nin => true,
else => false,
};
}
/// Whether a stored document (canonical BSON bytes) matches `filter` — the
/// byte-matcher counterpart of `matches`, used by scans. Same semantics,
/// different collection: fields the filter does not name are skipped by
/// length instead of materialized.
pub fn matches_bytes(
gpa: std.mem.Allocator,
filter: []const bson.Pair,
bytes: []const u8,
) QueryError!bool {
for (filter) |p| {
if (p.key.len > 0 and p.key[0] == '$') {
if (!try match_top_level_bytes(gpa, p.key, p.value, bytes)) return false;
} else {
if (!try field_matches_bytes(gpa, p.key, p.value, bytes)) return false;
}
}
return true;
}
fn match_top_level_bytes(
gpa: std.mem.Allocator,
op: []const u8,
value: bson.Value,
bytes: []const u8,
) QueryError!bool {
if (std.mem.eql(u8, op, "$and") or std.mem.eql(u8, op, "$or")) {
const want_and = std.mem.eql(u8, op, "$and");
const filters = switch (value) {
.array => |arr| arr,
else => return false,
};
for (filters) |item| {
const f = switch (item) {
.doc => |pairs| pairs,
else => return false,
};
const matched = try matches_bytes(gpa, f, bytes);
if (want_and and !matched) return false;
if (!want_and and matched) return true;
}
return want_and;
}
if (std.mem.eql(u8, op, "$nor")) {
const filters = switch (value) {
.array => |arr| arr,
else => return false,
};
for (filters) |item| {
const f = switch (item) {
.doc => |pairs| pairs,
else => return false,
};
if (try matches_bytes(gpa, f, bytes)) return false;
}
return true;
}
return false;
}
/// Collect values reachable at `path` from a document's canonical bytes —
/// the byte counterpart of `collect_values`, with the same traversal, the
/// same order and the same multikey semantics. Appends into `out`.
pub fn collect_values_bytes(
gpa: std.mem.Allocator,
bytes: []const u8,
path: []const u8,
out: *std.ArrayListUnmanaged(bson.Value),
depth: usize,
) QueryError!void {
var it = std.mem.splitScalar(u8, path, '.');
const first = it.next() orelse return;
const rest = it.rest();
var idx: usize = 4; // skip the document length prefix
while (idx + 1 < bytes.len and bytes[idx] != 0) {
const tag = bytes[idx];
idx += 1;
const key = bson.element_key(bytes, &idx) orelse return;
if (std.mem.eql(u8, key, first)) {
if (rest.len == 0) {
if (depth < 8) {
try out.append(gpa, try bson.read_value(gpa, bytes, &idx, tag));
} else {
try bson.skip_value(bytes, &idx, tag);
}
} else {
try collect_from_value_bytes(gpa, bytes, &idx, tag, rest, out, depth + 1);
}
} else {
try bson.skip_value(bytes, &idx, tag);
}
}
}
fn collect_from_value_bytes(
gpa: std.mem.Allocator,
bytes: []const u8,
idx: *usize,
tag: u8,
path: []const u8,
out: *std.ArrayListUnmanaged(bson.Value),
depth: usize,
) QueryError!void {
if (depth > 8) {
try bson.skip_value(bytes, idx, tag);
return;
}
switch (tag) {
0x03 => {
const start = idx.*;
try collect_values_bytes(gpa, bytes[start..], path, out, depth);
idx.* = start + std.mem.readInt(u32, bytes[start..][0..4], .little);
},
0x04 => {
const start = idx.*;
const total: u32 = std.mem.readInt(u32, bytes[start..][0..4], .little);
const array_end = start + total;
var pit = std.mem.splitScalar(u8, path, '.');
const seg = pit.next() orelse return;
if (std.fmt.parseInt(usize, seg, 10)) |aidx| {
var e: usize = 0;
var a = start + 4;
while (a < array_end - 1 and bytes[a] != 0) {
const atag = bytes[a];
a += 1;
_ = bson.element_key(bytes, &a) orelse return;
if (e == aidx) {
const rest = pit.rest();
if (rest.len == 0) {
if (depth < 8) {
try out.append(gpa, try bson.read_value(gpa, bytes, &a, atag));
} else {
try bson.skip_value(bytes, &a, atag);
}
} else {
try collect_from_value_bytes(gpa, bytes, &a, atag, rest, out, depth + 1);
}
break;
}
try bson.skip_value(bytes, &a, atag);
e += 1;
}
} else |_| {
// Multikey semantics: descend into embedded documents.
var a = start + 4;
while (a < array_end - 1 and bytes[a] != 0) {
const atag = bytes[a];
a += 1;
_ = bson.element_key(bytes, &a) orelse return;
if (atag == 0x03) {
const dstart = a;
try collect_values_bytes(gpa, bytes[dstart..], path, out, depth);
}
try bson.skip_value(bytes, &a, atag);
}
}
idx.* = array_end;
},
else => try bson.skip_value(bytes, idx, tag),
}
}
/// The query operators, resolved from their names once per filter field
/// instead of re-comparing strings for every candidate document.
const Op = enum {
eq,
ne,
gt,
gte,
lt,
lte,
in,
nin,
exists,
regex,
options,
not,
size,
all,
elem_match,
/// Not an operator we implement; matches nothing, as before.
unknown,
};
const op_names = std.StaticStringMap(Op).initComptime(.{
.{ "$eq", .eq },
.{ "$ne", .ne },
.{ "$gt", .gt },
.{ "$gte", .gte },
.{ "$lt", .lt },
.{ "$lte", .lte },
.{ "$in", .in },
.{ "$nin", .nin },
.{ "$exists", .exists },
.{ "$regex", .regex },
.{ "$options", .options },
.{ "$not", .not },
.{ "$size", .size },
.{ "$all", .all },
.{ "$elemMatch", .elem_match },
});
fn parse_op(name: []const u8) Op {
return op_names.get(name) orelse .unknown;
}
fn match_operator(
gpa: std.mem.Allocator,
op: Op,
value: bson.Value,
actuals_at_path: []const bson.Value,
regex_options: []const u8,
) QueryError!bool {
const actuals = if (equality_family(op)) equality_candidates(actuals_at_path) else actuals_at_path;
if (op == .eq) {
for (actuals) |a| if (bson.compare(a, value) == .eq) return true;
return false;
}
if (op == .ne) {
for (actuals) |a| if (bson.compare(a, value) == .eq) return false;
return true;
}
if (op == .gt or op == .gte or op == .lt or op == .lte) {
for (actuals) |a| {
const o = bson.compare(a, value);
const hit = switch (op) {
.gt => o == .gt,
.gte => o != .lt,
.lt => o == .lt,
.lte => o != .gt,
else => unreachable,
};
if (hit) return true;
}
return false;
}
if (op == .in or op == .nin) {
const members = switch (value) {
.array => |arr| arr,
else => return false,
};
const want_in = op == .in;
for (actuals) |a| {
for (members) |m| {
if (bson.compare(a, m) == .eq) return want_in;
}
}
return !want_in;
}
if (op == .exists) {
const want = switch (value) {
.bool => |b| b,
else => return false,
};
return (actuals.len > 0) == want;
}
if (op == .regex) {
const pattern = switch (value) {
.string => |s| s,
.doc => |pairs| blk: {
const pat = bson.get_pair(pairs, "$regex") orelse return false;
break :blk switch (pat) {
.string => |s| s,
else => return false,
};
},
else => return false,
};
for (actuals) |a| {
if (a == .string and regex_match(pattern, regex_options, a.string)) return true;
}
return false;
}
if (op == .not) {
const pairs = is_operator_doc(value) orelse {
// $not with a bare value means $ne-ish semantics; treat as
// "not equal to this regex or value".
if (value == .string) {
for (actuals) |a| {
if (a == .string and regex_match(value.string, "", a.string)) return false;
}
return true;
}
return false;
};
for (pairs) |p| {
if (try match_operator(gpa, parse_op(p.key), p.value, actuals, regex_options)) return false;
}
return true;
}
if (op == .size) {
const want = switch (value) {
.int32 => |i| i,
.int64 => |i| @as(i32, @intCast(i)),
else => return false,
};
for (actuals) |a| {
if (a == .array and a.array.len == @as(usize, @intCast(want))) return true;
}
return false;
}
if (op == .all) {
const members = switch (value) {
.array => |arr| arr,
else => return false,
};
outer: for (members) |m| {
for (actuals) |a| {
if (a == .array) {
for (a.array) |elem| {
if (bson.compare(elem, m) == .eq) continue :outer;
}
}
}
return false;
}
return true;
}
if (op == .elem_match) {
const operand = switch (value) {
.doc => |pairs| pairs,
else => return false,
};
const all_operators = all_operator_keys(operand);
for (actuals) |a| {
if (a != .array) continue;
for (a.array) |elem| {
if (all_operators) {
var single: [1]bson.Value = .{elem};
var ok = true;
for (operand) |p| {
if (!try match_operator(gpa, parse_op(p.key), p.value, single[0..], "")) {
ok = false;
break;
}
}
if (ok) return true;
} else {
switch (elem) {
.doc => |pairs| {
if (try matches(gpa, &.{ .arena = undefined, .pairs = operand }, &.{ .arena = undefined, .pairs = pairs })) return true;
},
else => {},
}
}
}
}
return false;
}
return false;
}
/// Collect values reachable at `path` (dot-separated), descending into
/// documents and, per MongoDB multikey semantics, into arrays of documents.
/// Appends into `out`; on OOM, collection stops early (the engine is
/// already failing at that point). Public because index entry generation
/// must mirror field_matches exactly (src/index.zig).
pub fn collect_values(
gpa: std.mem.Allocator,
pairs: []const bson.Pair,
path: []const u8,
out: *std.ArrayListUnmanaged(bson.Value),
depth: usize,
) QueryError!void {
var it = std.mem.splitScalar(u8, path, '.');
const first = it.next() orelse return;
for (pairs) |p| {
if (!std.mem.eql(u8, p.key, first)) continue;
const rest = it.rest();
if (rest.len == 0) {
if (depth < 8) {
try out.append(gpa, p.value);
}
} else {
try collect_from_value(gpa, p.value, rest, out, depth + 1);
}
}
}
fn collect_from_value(
gpa: std.mem.Allocator,
v: bson.Value,
path: []const u8,
out: *std.ArrayListUnmanaged(bson.Value),
depth: usize,
) QueryError!void {
if (depth > 8) return;
switch (v) {
.doc => |pairs| try collect_values(gpa, pairs, path, out, depth),
.array => |items| {
// Numeric first segment: address an element by index ("tags.0").
var pit = std.mem.splitScalar(u8, path, '.');
const seg = pit.next() orelse return;
if (std.fmt.parseInt(usize, seg, 10)) |idx| {
if (idx < items.len) {
const rest = pit.rest();
if (rest.len == 0) {
if (depth < 8) try out.append(gpa, items[idx]);
} else {
try collect_from_value(gpa, items[idx], rest, out, depth + 1);
}
}
return;
} else |_| {}
// Multikey semantics: descend into embedded documents of the array.
for (items) |item| {
switch (item) {
.doc => try collect_values(gpa, item.doc, path, out, depth),
else => {},
}
}
},
else => return,
}
}
// ---------------------------------------------------------------------------
// Regex (subset): ^ $ . * + ? [...] ( ) | and escaped literals
// ---------------------------------------------------------------------------
pub fn regex_match(pattern: []const u8, options: []const u8, text: []const u8) bool {
// Bound recursion depth: deeply nested groups would overflow the stack.
var depth: usize = 0;
var max_depth: usize = 0;
var ri: usize = 0;
while (ri < pattern.len) : (ri += 1) {
if (pattern[ri] == '\\') {
ri += 1;
continue;
}
if (pattern[ri] == '(') {
depth += 1;
max_depth = @max(max_depth, depth);
} else if (pattern[ri] == ')') {
depth -|= 1;
}
}
if (max_depth > 256) return false;
const case_insensitive = std.mem.indexOfScalar(u8, options, 'i') != null;
const dot_all = std.mem.indexOfScalar(u8, options, 's') != null;
const anchored = pattern.len > 0 and pattern[0] == '^';
const start_pattern = if (anchored) pattern[1..] else pattern;
var p: usize = 0;
if (anchored) {
if (match_here(start_pattern, &p, text, 0, case_insensitive, dot_all) == null) return false;
return p == start_pattern.len;
}
var t: usize = 0;
while (t <= text.len) : (t += 1) {
p = 0;
if (match_here(start_pattern, &p, text, t, case_insensitive, dot_all) != null and p == start_pattern.len) return true;
}
return false;
}
/// Match `pattern[p..]` against `text[t..]`, returning the new text
/// position on success (null on failure). Backtracks via recursion.
fn match_here(
pattern: []const u8,
p: *usize,
text: []const u8,
t: usize,
ci: bool,
dot_all: bool,
) ?usize {
var pos = t;
while (p.* < pattern.len) {
const c = pattern[p.*];
switch (c) {
'$' => {
if (p.* + 1 == pattern.len) {
p.* += 1;
return if (pos == text.len) pos else null;
}
if (pos >= text.len) return null;
if (!chars_eq(c, text[pos], ci)) return null;
p.* += 1;
pos += 1;
},
'^' => {
if (pos != 0) return null;
p.* += 1;
},
'(' => {
const end = find_group_end(pattern, p.*) orelse return null;
const inner = pattern[p.* + 1 .. end - 1];
var parts: [8][]const u8 = undefined;
var nparts: usize = 0;
var seg_start: usize = 0;
var depth: usize = 0;
var i: usize = 0;
while (i < inner.len) : (i += 1) {
const ic = inner[i];
if (ic == '\\') {
i += 1;
continue;
}
if (ic == '(') depth +|= 1;
if (ic == ')') depth -|= 1;
if (ic == '|' and depth == 0) {
if (nparts < parts.len) parts[nparts] = inner[seg_start..i];
nparts += 1;
seg_start = i + 1;
}
}
if (nparts < parts.len) parts[nparts] = inner[seg_start..];
nparts += 1;
if (nparts == 1) {
var gp: usize = 0;
const after = match_here(parts[0], &gp, text, pos, ci, dot_all) orelse return null;
if (gp != parts[0].len) return null;
p.* = end;
pos = after;
} else {
var matched = false;
for (parts[0..nparts]) |part| {
var gp: usize = 0;
const after = match_here(part, &gp, text, pos, ci, dot_all) orelse continue;
if (gp == part.len) {
p.* = end;
pos = after;
matched = true;
break;
}
}
if (!matched) return null;
}
},
')' => return null, // unbalanced
'\\' => {
if (p.* + 1 >= pattern.len) return null;
const lit = pattern[p.* + 1];
if (pos >= text.len or !chars_eq(lit, text[pos], ci)) return null;
p.* += 2;
pos += 1;
},
else => {
var element_end: usize = undefined;
if (c == '[') {
var close = p.* + 1;
if (close < pattern.len and pattern[close] == '^') close += 1;
while (close < pattern.len and pattern[close] != ']') close += 1;
if (close >= pattern.len) return null;
element_end = close + 1;
} else {
element_end = p.* + 1;
}
const element = pattern[p.*..element_end];
var q_end = element_end;
var min: usize = 1;
var max: usize = 1;
if (element_end < pattern.len and (pattern[element_end] == '*' or pattern[element_end] == '+' or pattern[
element_end
] == '?')) {
switch (pattern[element_end]) {
'*' => {
min = 0;
max = std.math.maxInt(usize);
},
'+' => {
min = 1;
max = std.math.maxInt(usize);
},
'?' => {
min = 0;
},
else => {},
}
q_end = element_end + 1;
}
// Greedy: consume as many as possible, then backtrack.
var consumed: usize = 0;
var pos_cur = pos;
while (max == std.math.maxInt(usize) or consumed < max) {
if (element_matches(element, text, pos_cur, ci, dot_all)) {
pos_cur += 1;
consumed += 1;
} else break;
}
var attempt = consumed;
while (attempt >= min) : (attempt -= 1) {
p.* = q_end;
if (match_here(pattern, p, text, pos_cur - (consumed - attempt), ci, dot_all)) |after| {
return after;
}
if (attempt == 0) break;
}
return null;
},
}
}
return pos;
}
fn chars_eq(a: u8, b: u8, ci: bool) bool {
if (ci) return std.ascii.toLower(a) == std.ascii.toLower(b);
return a == b;
}
fn in_range(lo: u8, hi: u8, c: u8, ci: bool) bool {
if (ci) {
const l = std.ascii.toLower(c);
return l >= std.ascii.toLower(lo) and l <= std.ascii.toLower(hi);
}
return c >= lo and c <= hi;
}
fn find_group_end(pattern: []const u8, open: usize) ?usize {
var depth: usize = 1;
var i = open + 1;
while (i < pattern.len) : (i += 1) {
if (pattern[i] == '\\') {
i += 1;
continue;
}
if (pattern[i] == '(') depth += 1;
if (pattern[i] == ')') {
depth -= 1;
if (depth == 0) return i + 1;
}
}
return null;
}
fn element_matches(element: []const u8, text: []const u8, t: usize, ci: bool, dot_all: bool) bool {
if (t >= text.len) return false;
if (element[0] == '.') {
return dot_all or text[t] != '\n';
}
if (element[0] == '[') {
var negated = false;
var i: usize = 1;
if (i < element.len and element[i] == '^') {
negated = true;
i += 1;
}
var matched = false;
while (i < element.len and element[i] != ']') {
if (i + 2 < element.len and element[i + 1] == '-') {
if (in_range(element[i], element[i + 2], text[t], ci)) matched = true;
i += 3;
} else {
if (chars_eq(element[i], text[t], ci)) matched = true;
i += 1;
}
}
return matched != negated;
}
return chars_eq(element[0], text[t], ci);
}
// ---------------------------------------------------------------------------
// Sort
// ---------------------------------------------------------------------------
pub const SortKey = struct {
path: []const u8,
descending: bool,
};
/// The first value `collect_values` would append at `path`, or null when it
/// would append none. Same traversal in the same order, same depth cutoff —
/// it just stops at the first hit instead of building a list.
///
/// Sorting only ever reads element 0 of the collected list, so materializing
/// the rest cost one allocation per document per sort key.
pub fn first_value_at(pairs: []const bson.Pair, path: []const u8, depth: usize) ?bson.Value {
var it = std.mem.splitScalar(u8, path, '.');
const first = it.next() orelse return null;
const rest = it.rest();
for (pairs) |p| {
if (!std.mem.eql(u8, p.key, first)) continue;
if (rest.len == 0) {
if (depth < 8) return p.value;
} else {
if (first_from_value(p.value, rest, depth + 1)) |v| return v;
}
}
return null;
}
fn first_from_value(v: bson.Value, path: []const u8, depth: usize) ?bson.Value {
if (depth > 8) return null;
switch (v) {
.doc => |pairs| return first_value_at(pairs, path, depth),
.array => |items| {
var pit = std.mem.splitScalar(u8, path, '.');
const seg = pit.next() orelse return null;
if (std.fmt.parseInt(usize, seg, 10)) |idx| {
if (idx >= items.len) return null;
const rest = pit.rest();
if (rest.len == 0) {
if (depth < 8) return items[idx];
return null;
}
return first_from_value(items[idx], rest, depth + 1);
} else |_| {}
for (items) |item| {
switch (item) {
.doc => if (first_value_at(item.doc, path, depth)) |x| return x,
else => {},
}
}
return null;
},
else => return null,
}
}
/// A document paired with the one value per sort key the comparator reads.
const SortedDoc = struct {
doc: *const bson.Document,
/// `keys.len` values; a path that yields nothing sorts as null.
vals: []const bson.Value,
};
const SortCtx = struct {
keys: []const SortKey,
fn less(ctx: @This(), a: SortedDoc, b: SortedDoc) bool {
for (ctx.keys, 0..) |k, ki| {
const o = bson.compare(a.vals[ki], b.vals[ki]);
if (o != .eq) return if (k.descending) o == .gt else o == .lt;
}
return false;
}
};
/// Pull each document's sort-key values into one flat allocation, so the
/// comparator is pure and cannot fail.
fn decorate(
arena: std.mem.Allocator,
docs: []*const bson.Document,
keys: []const SortKey,
) QueryError![]SortedDoc {
const entries = try arena.alloc(SortedDoc, docs.len);
const flat = try arena.alloc(bson.Value, docs.len * keys.len);
for (docs, 0..) |d, i| {
const vals = flat[i * keys.len ..][0..keys.len];
for (keys, 0..) |k, ki| {
vals[ki] = first_value_at(d.pairs, k.path, 0) orelse .null;
}
entries[i] = .{ .doc = d, .vals = vals };
}
return entries;
}
/// Sort `docs` in place by `keys`.
pub fn sort_docs(
arena: std.mem.Allocator,
docs: []*const bson.Document,
keys: []const SortKey,
) QueryError!void {
if (keys.len == 0 or docs.len < 2) return;
const entries = try decorate(arena, docs, keys);
std.mem.sort(SortedDoc, entries, SortCtx{ .keys = keys }, SortCtx.less);
for (entries, 0..) |e, i| docs[i] = e.doc;
}
/// Place the `k` smallest documents by `keys`, in order, at the front of
/// `docs`. **`docs[k..]` is left in an unspecified order** — callers must
/// only read the first `k`.
///
/// A query that sorts a whole collection to return one page pays
/// n log n comparisons to discard almost all of the result. This keeps a
/// k-element max-heap instead: one comparison against the heap root per
/// document, and only the survivors are ever ordered.
pub fn sort_docs_top_k(
arena: std.mem.Allocator,
docs: []*const bson.Document,
keys: []const SortKey,
k: usize,
) QueryError!void {
if (keys.len == 0 or docs.len < 2) return;
if (k == 0) return;
if (k >= docs.len) return sort_docs(arena, docs, keys);
const entries = try decorate(arena, docs, keys);
const ctx = SortCtx{ .keys = keys };
// Max-heap over the first k: the root is the worst of the best-so-far.
var i = k / 2;
while (i > 0) {
i -= 1;
sift_down(entries[0..k], i, ctx);
}
// Anything better than the root replaces it; anything worse is dropped
// after a single comparison.
for (entries[k..]) |e| {
if (!ctx.less(e, entries[0])) continue;
entries[0] = e;
sift_down(entries[0..k], 0, ctx);
}
// Drain the heap back-to-front, which leaves entries[0..k] ascending.
var end = k;
while (end > 1) {
end -= 1;
const tmp = entries[0];
entries[0] = entries[end];
entries[end] = tmp;
sift_down(entries[0..end], 0, ctx);
}
for (entries[0..k], 0..) |e, j| docs[j] = e.doc;
}
/// Restore the max-heap property at `root` over `heap`.
fn sift_down(heap: []SortedDoc, root: usize, ctx: SortCtx) void {
var parent = root;
while (true) {
const left = parent * 2 + 1;
if (left >= heap.len) return;
const right = left + 1;
// The larger child under `less`, i.e. the one that must rise.
var largest = left;
if (right < heap.len and ctx.less(heap[left], heap[right])) largest = right;
if (!ctx.less(heap[parent], heap[largest])) return;
const tmp = heap[parent];
heap[parent] = heap[largest];
heap[largest] = tmp;
parent = largest;
}
}
// ---------------------------------------------------------------------------
// Projection
// ---------------------------------------------------------------------------
pub const ProjectionError = std.mem.Allocator.Error;
/// Apply a projection document, writing resulting pairs into `out` (which
/// should use the caller's arena so strings are owned).
pub fn project(
arena: std.mem.Allocator,
doc: *const bson.Document,
proj: *const bson.Document,
out: *std.ArrayListUnmanaged(bson.Pair),
) ProjectionError!void {
var inclusion: ?bool = null;
var non_id_count: usize = 0;
for (proj.pairs) |p| {
if (std.mem.eql(u8, p.key, "_id")) continue;
non_id_count += 1;
const flag = truthy(p.value);
inclusion = if (inclusion == null) flag else inclusion;
}
// {_id: 0} alone means "drop _id, keep everything else".
const include = if (non_id_count > 0) (inclusion orelse true) else false;
if (include) {
// Inclusion list: _id unless excluded, plus listed paths.
var include_id = true;
if (bson.get_pair(proj.pairs, "_id")) |idv| {
include_id = truthy(idv);
}
if (include_id) {
if (bson.get_pair(doc.pairs, "_id")) |idv| {
try out.append(arena, .{ .key = "_id", .value = try bson.copy_value(arena, idv) });
}
}
for (proj.pairs) |p| {
if (std.mem.eql(u8, p.key, "_id")) continue;
if (!truthy(p.value)) continue;
try project_path(arena, doc.pairs, p.key, out);
}
} else {
// Exclusion: copy everything except excluded paths (and _id if set).
for (doc.pairs) |p| {
if (std.mem.eql(u8, p.key, "_id")) {
var excluded = false;
if (bson.get_pair(proj.pairs, "_id")) |idv| excluded = !truthy(idv);
if (excluded) continue;
}
if (is_excluded(proj, p.key)) continue;
if (p.value == .doc and has_deeper_exclusion(proj, p.key)) {
const filtered = try exclude_doc(arena, p.value.doc, proj, p.key);
try out.append(arena, .{ .key = try arena.dupe(u8, p.key), .value = .{ .doc = filtered } });
} else {
try out.append(arena, .{ .key = try arena.dupe(u8, p.key), .value = try bson.copy_value(arena, p.value) });
}
}
}
}
/// Recursively apply exclusions to a nested document given the parent path.
fn exclude_doc(
arena: std.mem.Allocator,
pairs: []const bson.Pair,
proj: *const bson.Document,
parent: []const u8,
) ProjectionError![]const bson.Pair {
var out: std.ArrayListUnmanaged(bson.Pair) = .empty;
errdefer out.deinit(arena);
for (pairs) |p| {
const full = if (parent.len > 0) try std.fmt.allocPrint(arena, "{s}.{s}", .{ parent, p.key }) else p.key;
if (is_excluded(proj, full)) continue;
if (p.value == .doc and has_deeper_exclusion(proj, full)) {
const filtered = try exclude_doc(arena, p.value.doc, proj, full);
try out.append(arena, .{ .key = try arena.dupe(u8, p.key), .value = .{ .doc = filtered } });
} else {
try out.append(arena, .{ .key = try arena.dupe(u8, p.key), .value = try bson.copy_value(arena, p.value) });
}
}
return out.toOwnedSlice(arena);
}
fn is_excluded(proj: *const bson.Document, key: []const u8) bool {
return bson.get_pair(proj.pairs, key) != null;
}
fn has_deeper_exclusion(proj: *const bson.Document, key: []const u8) bool {
for (proj.pairs) |pp| {
if (is_prefix_or_equal(key, pp.key)) return true;
}
return false;
}
/// MongoDB's truthiness for an option/flag value (projection flags, index
/// spec options).
pub fn truthy(v: bson.Value) bool {
return switch (v) {
.bool => |b| b,
.int32 => |i| i != 0,
.int64 => |i| i != 0,
.double => |d| d != 0,
else => false,
};
}
/// Include a dotted path (e.g. "a.b.c"), creating nested documents as needed.
fn project_path(
arena: std.mem.Allocator,
pairs: []const bson.Pair,
path: []const u8,
out: *std.ArrayListUnmanaged(bson.Pair),
) ProjectionError!void {
var it = std.mem.splitScalar(u8, path, '.');
const first = it.next() orelse return;
const rest = it.rest();
// Does the doc have this top-level field?
const field = bson.get_pair(pairs, first);
if (rest.len == 0) {
if (field) |f| {
try out.append(arena, .{ .key = try arena.dupe(u8, first), .value = try bson.copy_value(arena, f) });
}
return;
}
if (field) |f| {
switch (f) {
.doc => |sub| {
var nested: std.ArrayListUnmanaged(bson.Pair) = .empty;
errdefer nested.deinit(arena);
try project_path(arena, sub, rest, &nested);
if (nested.items.len > 0) {
try out.append(arena, .{ .key = try arena.dupe(u8, first), .value = .{ .doc = try nested.toOwnedSlice(arena) } });
}
},
else => {},
}
}
}
fn is_prefix_or_equal(prefix: []const u8, key: []const u8) bool {
if (std.mem.eql(u8, prefix, key)) return true;
if (prefix.len < key.len and std.mem.eql(u8, prefix, key[0..prefix.len])) {
return key[prefix.len] == '.';
}
return false;
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
const testing = std.testing;
fn doc_of(pairs: []const bson.Pair) bson.Document {
return .{ .arena = undefined, .pairs = pairs };
}
test "basic filters" {
const d = doc_of(&.{
.{ .key = "_id", .value = .{ .int32 = 1 } },
.{ .key = "age", .value = .{ .int32 = 30 } },
.{ .key = "name", .value = .{ .string = "alice" } },
.{ .key = "tags", .value = .{ .array = &.{ .{ .string = "a" }, .{ .string = "b" } } } },
});
try testing.expect(try matches(testing.allocator, &doc_of(&.{}), &d));
try testing.expect(try matches(testing.allocator, &doc_of(&.{.{ .key = "age", .value = .{ .int32 = 30 } }}), &d));
try testing.expect(!try matches(testing.allocator, &doc_of(&.{.{ .key = "age", .value = .{ .int32 = 31 } }}), &d));
try testing.expect(try matches(testing.allocator, &doc_of(&.{.{ .key = "age", .value = .{ .doc = &.{.{ .key = "$gt", .value = .{ .int32 = 20 } }} } }}), &d));
try testing.expect(!try matches(testing.allocator, &doc_of(&.{.{ .key = "age", .value = .{ .doc = &.{.{ .key = "$gt", .value = .{ .int32 = 30 } }} } }}), &d));
try testing.expect(try matches(testing.allocator, &doc_of(&.{.{ .key = "age", .value = .{ .doc = &.{.{ .key = "$in", .value = .{ .array = &.{ .{ .int32 = 29 }, .{ .int32 = 30 } } } }} } }}), &d));
try testing.expect(try matches(testing.allocator, &doc_of(&.{.{ .key = "missing", .value = .{ .doc = &.{.{ .key = "$exists", .value = .{ .bool = false } }} } }}), &d));
try testing.expect(try matches(testing.allocator, &doc_of(&.{.{ .key = "tags", .value = .{ .string = "b" } }}), &d));
try testing.expect(try matches(testing.allocator, &doc_of(&.{.{ .key = "tags", .value = .{ .doc = &.{.{ .key = "$size", .value = .{ .int32 = 2 } }} } }}), &d));
}
test "regex filter" {
const d = doc_of(&.{.{ .key = "name", .value = .{ .string = "Alice Smith" } }});
try testing.expect(try matches(testing.allocator, &doc_of(&.{.{ .key = "name", .value = .{ .doc = &.{
.{ .key = "$regex", .value = .{ .string = "^al" } },
.{ .key = "$options", .value = .{ .string = "i" } },
} } }}), &d));
try testing.expect(!try matches(testing.allocator, &doc_of(&.{.{ .key = "name", .value = .{ .doc = &.{.{ .key = "$regex", .value = .{ .string = "^X" } }} } }}), &d));
try testing.expect(!try matches(testing.allocator, &doc_of(&.{.{ .key = "name", .value = .{ .doc = &.{.{ .key = "$regex", .value = .{ .string = "sm[i]th$" } }} } }}), &d));
try testing.expect(try matches(testing.allocator, &doc_of(&.{.{ .key = "name", .value = .{ .doc = &.{.{ .key = "$regex", .value = .{ .string = "th$" } }} } }}), &d));
}
test "regex engine basics" {
try testing.expect(regex_match("abc", "", "xabcx"));
try testing.expect(regex_match("^abc", "", "abc"));
try testing.expect(!regex_match("^abc", "", "xabc"));
try testing.expect(regex_match("a.c", "", "abc"));
try testing.expect(regex_match("a*b", "", "aaab"));
try testing.expect(regex_match("a+b", "", "aab"));
try testing.expect(regex_match("colou?r", "", "color"));
try testing.expect(regex_match("colou?r", "", "colour"));
try testing.expect(regex_match("[0-9]+", "", "abc123def"));
try testing.expect(!regex_match("^[0-9]+$", "", "abc123"));
try testing.expect(regex_match("(ab|cd)e", "", "cde"));
try testing.expect(regex_match("a\\.b", "", "a.b"));
try testing.expect(regex_match("^foo$", "", "foo"));
try testing.expect(!regex_match("^foo$", "", "foobar"));
try testing.expect(regex_match("hello", "i", "HELLO"));
try testing.expect(regex_match("^a+$", "", "aaaa"));
try testing.expect(!regex_match("^a+$", "", "aaab"));
}
test "regex hostile input does not crash" {
// Escaped paren inside a group used to underflow the alternation scan.
// Pattern is: "(" ++ "\" ++ "))" — a group containing an escaped ')'.
const hostile = "(" ++ "\\" ++ "))";
try testing.expect(regex_match(hostile, "", ")"));
try testing.expect(!regex_match(hostile, "", "x"));
try testing.expect(regex_match("a" ++ "\\" ++ "|b", "", "a|b"));
// Deeply nested groups must be rejected, not blow the stack.
const deep = "(" ** 300 ++ "x" ++ ")" ** 300;
try testing.expect(!regex_match(deep, "", "x"));
}
test "long array values are not truncated" {
var items: [30]bson.Value = undefined;
for (0..30) |i| items[i] = .{ .int32 = @intCast(i) };
const d = doc_of(&.{.{ .key = "tags", .value = .{ .array = &items } }});
try testing.expect(try matches(testing.allocator, &doc_of(&.{.{ .key = "tags", .value = .{ .int32 = 29 } }}), &d));
try testing.expect(!try matches(testing.allocator, &doc_of(&.{.{ .key = "tags", .value = .{ .int32 = 30 } }}), &d));
}
test "multikey path with several array candidates does not use-after-free" {
// Two array candidates at the path; flattening the first appends past
// the initial capacity, which used to realloc the buffer while a
// captured slice was still being iterated.
var big: [20]bson.Value = undefined;
for (0..20) |j| big[j] = .{ .int32 = @intCast(j) };
const d = doc_of(&.{.{ .key = "items", .value = .{ .array = &.{
.{ .doc = &.{.{ .key = "tags", .value = .{ .array = &big } }} },
.{ .doc = &.{.{ .key = "tags", .value = .{ .array = &.{.{ .string = "needle" }} } }} },
} } }});
try testing.expect(try matches(testing.allocator, &doc_of(&.{.{ .key = "items.tags", .value = .{ .string = "needle" } }}), &d));
// And the flattened long array is searched, not just the first element.
try testing.expect(try matches(testing.allocator, &doc_of(&.{.{ .key = "items.tags", .value = .{ .int32 = 19 } }}), &d));
}
test "OOM during value collection propagates, not a false match" {
// Candidate collection failing must surface as an error rather than
// leaving an empty candidate list, which would make negating operators
// like $ne and $exists:false report a match — a wrong answer, not a
// failed one.
//
// Collection only reaches the allocator once a path yields more than
// `inline_candidates` values, so use an array long enough to spill.
var many: [32]bson.Value = undefined;
for (&many, 0..) |*v, i| v.* = .{ .int32 = @intCast(i) };
const d = doc_of(&.{.{ .key = "x", .value = .{ .array = &many } }});
const ne = doc_of(&.{.{ .key = "x", .value = .{ .doc = &.{.{ .key = "$ne", .value = .{ .int32 = 999 } }} } }});
var buf: [16]u8 = undefined;
var fba = std.heap.FixedBufferAllocator.init(&buf);
try testing.expectError(error.OutOfMemory, matches(fba.allocator(), &ne, &d));
const ex = doc_of(&.{.{ .key = "x", .value = .{ .doc = &.{.{ .key = "$exists", .value = .{ .bool = false } }} } }});
try testing.expectError(error.OutOfMemory, matches(fba.allocator(), &ex, &d));
}
test "the common single-value match needs no allocator at all" {
// The counterpart to the test above: a field yielding a handful of
// values is collected on the stack, so a scan does not allocate per
// filter field per document. A failing allocator must therefore still
// produce the correct answer rather than an error.
const d = doc_of(&.{
.{ .key = "x", .value = .{ .int32 = 5 } },
.{ .key = "s", .value = .{ .string = "hi" } },
});
var buf: [0]u8 = undefined;
var fba = std.heap.FixedBufferAllocator.init(&buf);
const failing = fba.allocator();
const eq = doc_of(&.{.{ .key = "x", .value = .{ .int32 = 5 } }});
try testing.expect(try matches(failing, &eq, &d));
const ne = doc_of(&.{.{ .key = "x", .value = .{ .doc = &.{.{ .key = "$ne", .value = .{ .int32 = 5 } }} } }});
try testing.expect(!try matches(failing, &ne, &d));
const missing = doc_of(&.{.{ .key = "zz", .value = .{ .doc = &.{.{ .key = "$exists", .value = .{ .bool = false } }} } }});
try testing.expect(try matches(failing, &missing, &d));
const range = doc_of(&.{.{ .key = "x", .value = .{ .doc = &.{
.{ .key = "$gte", .value = .{ .int32 = 1 } },
.{ .key = "$lt", .value = .{ .int32 = 10 } },
} } }});
try testing.expect(try matches(failing, &range, &d));
}
test "documents compare by field name too" {
try testing.expectEqual(std.math.Order.lt, bson.compare(
.{ .doc = &.{.{ .key = "a", .value = .{ .int32 = 1 } }} },
.{ .doc = &.{.{ .key = "b", .value = .{ .int32 = 1 } }} },
));
}
test "dot path filters" {
const d = doc_of(&.{
.{ .key = "user", .value = .{ .doc = &.{
.{ .key = "profile", .value = .{ .doc = &.{.{ .key = "age", .value = .{ .int32 = 25 } }} } },
} } },
.{ .key = "items", .value = .{ .array = &.{
.{ .doc = &.{.{ .key = "sku", .value = .{ .string = "x" } }} },
.{ .doc = &.{.{ .key = "sku", .value = .{ .string = "y" } }} },
} } },
});
try testing.expect(try matches(testing.allocator, &doc_of(&.{.{ .key = "user.profile.age", .value = .{ .int32 = 25 } }}), &d));
try testing.expect(!try matches(testing.allocator, &doc_of(&.{.{ .key = "user.profile.age", .value = .{ .int32 = 26 } }}), &d));
try testing.expect(try matches(testing.allocator, &doc_of(&.{.{ .key = "items.sku", .value = .{ .string = "y" } }}), &d));
}
test "array index dot path and bare regex value" {
const d = doc_of(&.{
.{ .key = "tags", .value = .{ .array = &.{
.{ .string = "c" },
.{ .string = "a" },
} } },
.{ .key = "name", .value = .{ .string = "carol" } },
});
// tags.0 addresses the first element.
try testing.expect(try matches(testing.allocator, &doc_of(&.{.{ .key = "tags.0", .value = .{ .string = "c" } }}), &d));
try testing.expect(!try matches(testing.allocator, &doc_of(&.{.{ .key = "tags.1", .value = .{ .string = "c" } }}), &d));
try testing.expect(!try matches(testing.allocator, &doc_of(&.{.{ .key = "tags.5", .value = .{ .string = "c" } }}), &d));
// Bare BSON regex value behaves like {$regex: "re"}.
try testing.expect(try matches(testing.allocator, &doc_of(&.{.{ .key = "name", .value = .{ .regex = .{ .pattern = "^c", .options = "" } } }}), &d));
try testing.expect(!try matches(testing.allocator, &doc_of(&.{.{ .key = "name", .value = .{ .regex = .{ .pattern = "^z", .options = "" } } }}), &d));
}
test "a missing field is null to equality and to nothing else" {
const gpa = testing.allocator;
// No `a` at all. The index layer has always stored this document under
// null in a non-sparse index; the matcher used to disagree, so
// `find({a: null})` answered nothing here and two documents on mongod.
const d = doc_of(&.{ .{ .key = "_id", .value = .{ .int32 = 1 } }, .{ .key = "z", .value = .{ .int32 = 1 } } });
// A table of static filters: `&.{...}` inside a `const` initializer is a
// comptime constant, where the same thing returned from a helper would be
// a pointer into that helper's dead frame.
const Case = struct { what: []const u8, filter: []const bson.Pair, want: bool };
const cases = [_]Case{
// The equality family sees a null...
.{ .what = "{a: null}", .want = true, .filter = &.{
.{ .key = "a", .value = .null },
} },
.{ .what = "$eq null", .want = true, .filter = &.{
.{ .key = "a", .value = .{ .doc = &.{.{ .key = "$eq", .value = .null }} } },
} },
.{ .what = "$in [null, 1]", .want = true, .filter = &.{
.{ .key = "a", .value = .{ .doc = &.{.{ .key = "$in", .value = .{ .array = &.{
.null,
.{ .int32 = 1 },
} } }} } },
} },
// ...including through a dotted path that stops short...
.{ .what = "{a.b: null}", .want = true, .filter = &.{
.{ .key = "a.b", .value = .null },
} },
// ...and the negations exclude it, which is the half that a bare
// "true when there are no candidates" would get backwards.
.{ .what = "$ne null", .want = false, .filter = &.{
.{ .key = "a", .value = .{ .doc = &.{.{ .key = "$ne", .value = .null }} } },
} },
.{ .what = "$nin [null]", .want = false, .filter = &.{
.{ .key = "a", .value = .{ .doc = &.{.{ .key = "$nin", .value = .{ .array = &.{.null} } }} } },
} },
// A non-null equality still finds nothing.
.{ .what = "{a: 1}", .want = false, .filter = &.{
.{ .key = "a", .value = .{ .int32 = 1 } },
} },
.{ .what = "$ne 1", .want = true, .filter = &.{
.{ .key = "a", .value = .{ .doc = &.{.{ .key = "$ne", .value = .{ .int32 = 1 } }} } },
} },
// Nothing outside the family sees it. Mutation check: widen
// `equality_family` to every operator, or collect the null in
// `field_matches` instead of substituting it here, and these flip.
.{ .what = "$lt 5", .want = false, .filter = &.{
.{ .key = "a", .value = .{ .doc = &.{.{ .key = "$lt", .value = .{ .int32 = 5 } }} } },
} },
.{ .what = "$gte null", .want = false, .filter = &.{
.{ .key = "a", .value = .{ .doc = &.{.{ .key = "$gte", .value = .null }} } },
} },
.{ .what = "$type null", .want = false, .filter = &.{
.{ .key = "a", .value = .{ .doc = &.{.{ .key = "$type", .value = .{ .string = "null" } }} } },
} },
.{ .what = "$size 0", .want = false, .filter = &.{
.{ .key = "a", .value = .{ .doc = &.{.{ .key = "$size", .value = .{ .int32 = 0 } }} } },
} },
.{ .what = "$exists false", .want = true, .filter = &.{
.{ .key = "a", .value = .{ .doc = &.{.{ .key = "$exists", .value = .{ .bool = false } }} } },
} },
};
// The byte matcher answers scans and the tree matcher answers everything
// else, so both are run over every case: a rule in one and not the other
// would show up as a filter that changes its mind after a checkpoint.
var bytes: std.ArrayListUnmanaged(u8) = .empty;
defer bytes.deinit(gpa);
try bson.write_doc(d.pairs, gpa, &bytes);
for (cases) |c| {
errdefer std.debug.print("case: {s}\n", .{c.what});
try testing.expectEqual(c.want, try matches(gpa, &doc_of(c.filter), &d));
try testing.expectEqual(c.want, try matches_bytes(gpa, c.filter, bytes.items));
}
// An explicit null is unaffected, and so is a value.
const eq_null: []const bson.Pair = &.{.{ .key = "a", .value = .null }};
const ne_null: []const bson.Pair = &.{
.{ .key = "a", .value = .{ .doc = &.{.{ .key = "$ne", .value = .null }} } },
};
const explicit = doc_of(&.{.{ .key = "a", .value = .null }});
try testing.expect(try matches(gpa, &doc_of(eq_null), &explicit));
try testing.expect(!try matches(gpa, &doc_of(ne_null), &explicit));
const valued = doc_of(&.{.{ .key = "a", .value = .{ .int32 = 1 } }});
try testing.expect(!try matches(gpa, &doc_of(eq_null), &valued));
try testing.expect(try matches(gpa, &doc_of(ne_null), &valued));
}
test "sort compares by BSON order" {
const a = doc_of(&.{ .{ .key = "n", .value = .{ .int32 = 2 } }, .{ .key = "x", .value = .{ .string = "a" } } });
const b = doc_of(&.{ .{ .key = "n", .value = .{ .int32 = 10 } }, .{ .key = "x", .value = .{ .string = "b" } } });
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
var docs = [_]*const bson.Document{ &b, &a };
const asc = [_]SortKey{.{ .path = "n", .descending = false }};
try sort_docs(arena.allocator(), &docs, &asc);
try testing.expect(docs[0] == &a);
try testing.expect(docs[1] == &b);
var docs2 = [_]*const bson.Document{ &a, &b };
const desc = [_]SortKey{.{ .path = "n", .descending = true }};
try sort_docs(arena.allocator(), &docs2, &desc);
try testing.expect(docs2[0] == &b);
const missing = [_]SortKey{.{ .path = "zz", .descending = false }};
try sort_docs(arena.allocator(), &docs2, &missing);
try testing.expect(docs2[0] == &b); // stable-ish: order untouched by missing key
}
test "first_value_at agrees with collect_values on its first element" {
// This equivalence is the entire correctness argument for the sort
// decorate pass, so exercise the traversal shapes that differ:
// dotted paths, arrays of documents (multikey), numeric element
// addressing, repeated keys, and the depth cutoff.
const gpa = testing.allocator;
const inner = [_]bson.Pair{
.{ .key = "x", .value = .{ .int32 = 7 } },
.{ .key = "y", .value = .{ .string = "deep" } },
};
const arr_docs = [_]bson.Value{
.{ .doc = &[_]bson.Pair{.{ .key = "v", .value = .{ .int32 = 1 } }} },
.{ .doc = &[_]bson.Pair{.{ .key = "v", .value = .{ .int32 = 2 } }} },
};
const plain_arr = [_]bson.Value{ .{ .int32 = 10 }, .{ .int32 = 20 } };
const d = doc_of(&.{
.{ .key = "n", .value = .{ .int32 = 5 } },
.{ .key = "sub", .value = .{ .doc = &inner } },
.{ .key = "items", .value = .{ .array = &arr_docs } },
.{ .key = "nums", .value = .{ .array = &plain_arr } },
// A repeated key: collect_values appends both, sorting takes the first.
.{ .key = "dup", .value = .{ .int32 = 100 } },
.{ .key = "dup", .value = .{ .int32 = 200 } },
});
const paths = [_][]const u8{
"n", "sub", "sub.x", "sub.y", "sub.missing",
"items", "items.v", "items.0", "items.1.v", "items.9",
"nums", "nums.0", "nums.1", "nums.5", "dup",
"missing", "n.deeper", "", "sub.x.y",
};
for (paths) |path| {
var list: std.ArrayListUnmanaged(bson.Value) = .empty;
defer list.deinit(gpa);
try collect_values(gpa, d.pairs, path, &list, 0);
const first = first_value_at(d.pairs, path, 0);
if (list.items.len == 0) {
testing.expect(first == null) catch |e| {
std.debug.print("path '{s}': collect empty but first_value_at returned a value\n", .{
path,
});
return e;
};
} else {
testing.expect(first != null) catch |e| {
std.debug.print("path '{s}': collect got {d} values but first_value_at returned null\n", .{
path,
list.items.len,
});
return e;
};
testing.expectEqual(std.math.Order.eq, bson.compare(list.items[0], first.?)) catch |e| {
std.debug.print("path '{s}': first value mismatch\n", .{path});
return e;
};
}
}
}
test "top-k selection matches a full sort on the leading page" {
const gpa = testing.allocator;
var arena = std.heap.ArenaAllocator.init(gpa);
defer arena.deinit();
// Values with many ties, so the heap's boundary behaviour is exercised.
var prng = std.Random.DefaultPrng.init(0xA11CE);
const rand = prng.random();
const n = 300;
var storage: [n]bson.Document = undefined;
var pairs: [n][2]bson.Pair = undefined;
for (0..n) |i| {
pairs[i] = .{
.{ .key = "a", .value = .{ .int32 = rand.intRangeAtMost(i32, 0, 9) } },
.{ .key = "b", .value = .{ .int32 = @intCast(i) } },
};
storage[i] = doc_of(pairs[i][0..]);
}
const key_sets = [_][]const SortKey{
&.{.{ .path = "a", .descending = false }},
&.{.{ .path = "a", .descending = true }},
// Second key breaks every tie, so the page is fully determined.
&.{ .{ .path = "a", .descending = false }, .{ .path = "b", .descending = false } },
&.{ .{ .path = "a", .descending = true }, .{ .path = "b", .descending = false } },
};
for (key_sets) |keys| {
for ([_]usize{ 1, 2, 20, 299, 300, 301 }) |k| {
var full: [n]*const bson.Document = undefined;
var topk: [n]*const bson.Document = undefined;
for (0..n) |i| {
full[i] = &storage[i];
topk[i] = &storage[i];
}
try sort_docs(arena.allocator(), &full, keys);
try sort_docs_top_k(arena.allocator(), &topk, keys, k);
const page = @min(k, n);
for (0..page) |i| {
// Ties make document identity ambiguous, so compare the
// sort keys rather than the pointers.
for (keys) |sk| {
const want = first_value_at(full[i].pairs, sk.path, 0) orelse bson.Value.null;
const got = first_value_at(topk[i].pairs, sk.path, 0) orelse bson.Value.null;
testing.expectEqual(std.math.Order.eq, bson.compare(want, got)) catch |e| {
std.debug.print("k={d} pos={d} key='{s}' diverged from the full sort\n", .{
k,
i,
sk.path,
});
return e;
};
}
}
}
}
}
test "projection inclusion and exclusion" {
const d = doc_of(&.{
.{ .key = "_id", .value = .{ .int32 = 1 } },
.{ .key = "a", .value = .{ .int32 = 1 } },
.{ .key = "b", .value = .{ .int32 = 2 } },
.{ .key = "nested", .value = .{ .doc = &.{
.{ .key = "x", .value = .{ .int32 = 3 } },
.{ .key = "y", .value = .{ .int32 = 4 } },
} } },
});
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
var out: std.ArrayListUnmanaged(bson.Pair) = .empty;
defer out.deinit(arena.allocator());
try project(arena.allocator(), &d, &doc_of(&.{
.{ .key = "a", .value = .{ .int32 = 1 } },
.{ .key = "nested.x", .value = .{ .int32 = 1 } },
}), &out);
try testing.expectEqual(@as(usize, 3), out.items.len);
try testing.expect(bson.get_pair(out.items, "a") != null);
try testing.expect(bson.get_pair(out.items, "b") == null);
const nx = bson.get_pair(out.items, "nested").?;
try testing.expectEqual(@as(usize, 1), nx.doc.len);
try testing.expectEqualStrings("x", nx.doc[0].key);
// {_id: 0} alone: everything except _id.
out.clearRetainingCapacity();
try project(arena.allocator(), &d, &doc_of(&.{.{ .key = "_id", .value = .{ .int32 = 0 } }}), &out);
try testing.expect(bson.get_pair(out.items, "_id") == null);
try testing.expect(bson.get_pair(out.items, "a") != null);
try testing.expect(bson.get_pair(out.items, "b") != null);
// Mixed inclusion with _id: 0: only listed fields, no _id.
out.clearRetainingCapacity();
try project(arena.allocator(), &d, &doc_of(&.{
.{ .key = "_id", .value = .{ .int32 = 0 } },
.{ .key = "a", .value = .{ .int32 = 1 } },
}), &out);
try testing.expect(bson.get_pair(out.items, "_id") == null);
try testing.expect(bson.get_pair(out.items, "a") != null);
try testing.expect(bson.get_pair(out.items, "b") == null);
out.clearRetainingCapacity();
try project(arena.allocator(), &d, &doc_of(&.{
.{ .key = "b", .value = .{ .int32 = 0 } },
.{ .key = "nested.x", .value = .{ .int32 = 0 } },
}), &out);
try testing.expect(bson.get_pair(out.items, "a") != null);
try testing.expect(bson.get_pair(out.items, "b") == null);
const n = bson.get_pair(out.items, "nested").?;
try testing.expectEqual(@as(usize, 1), n.doc.len);
try testing.expectEqualStrings("y", n.doc[0].key);
}
test "and/or filters" {
const d = doc_of(&.{ .{ .key = "a", .value = .{ .int32 = 1 } }, .{ .key = "b", .value = .{ .int32 = 2 } } });
try testing.expect(try matches(testing.allocator, &doc_of(&.{.{ .key = "$and", .value = .{ .array = &.{
.{ .doc = &.{.{ .key = "a", .value = .{ .int32 = 1 } }} },
.{ .doc = &.{.{ .key = "b", .value = .{ .int32 = 2 } }} },
} } }}), &d));
try testing.expect(try matches(testing.allocator, &doc_of(&.{.{ .key = "$or", .value = .{ .array = &.{
.{ .doc = &.{.{ .key = "a", .value = .{ .int32 = 99 } }} },
.{ .doc = &.{.{ .key = "b", .value = .{ .int32 = 2 } }} },
} } }}), &d));
try testing.expect(!try matches(testing.allocator, &doc_of(&.{.{ .key = "$or", .value = .{ .array = &.{
.{ .doc = &.{.{ .key = "a", .value = .{ .int32 = 99 } }} },
.{ .doc = &.{.{ .key = "b", .value = .{ .int32 = 99 } }} },
} } }}), &d));
}
test "byte matcher agrees with the tree matcher on a corpus" {
// The scan path matches stored documents as canonical BSON bytes,
// skipping fields by length; the tree path walks materialized pairs.
// They must agree exactly, so the byte matcher is checked against the
// existing matcher over a corpus that exercises scalars, operators,
// dot paths, multikey arrays, nested docs, $and/$or and missing fields.
const gpa = testing.allocator;
var prng = std.Random.DefaultPrng.init(0xB17E_7E);
const rand = prng.random();
const n = 200;
var bytes_list: std.ArrayListUnmanaged([]u8) = .empty;
defer {
for (bytes_list.items) |b| gpa.free(b);
bytes_list.deinit(gpa);
}
for (0..n) |_| {
const a = rand.intRangeAtMost(i32, 0, 10);
var pairs: [4]bson.Pair = undefined;
var np: usize = 0;
pairs[np] = .{ .key = "a", .value = .{ .int32 = a } };
np += 1;
pairs[np] = .{ .key = "b", .value = .{ .string = if (rand.boolean()) "x" else "y" } };
np += 1;
if (rand.boolean()) {
pairs[np] = .{ .key = "tags", .value = .{ .array = &.{ .{ .string = "m" }, .{ .string = "n" } } } };
np += 1;
}
if (rand.boolean()) {
pairs[np] = .{ .key = "d", .value = .{ .doc = &.{.{ .key = "e", .value = .{ .int32 = a } }} } };
np += 1;
}
var out: std.ArrayListUnmanaged(u8) = .empty;
defer out.deinit(gpa);
try bson.write_doc(pairs[0..np], gpa, &out);
try bytes_list.append(gpa, try gpa.dupe(u8, out.items));
}
for (0..500) |case| {
const a = rand.intRangeAtMost(i32, 0, 12);
const s = if (rand.boolean()) "x" else "m";
// Values are copied into a per-iteration arena so nested literals
// cannot dangle.
var farena = std.heap.ArenaAllocator.init(gpa);
defer farena.deinit();
const fa = farena.allocator();
var f_pairs: std.ArrayListUnmanaged(bson.Pair) = .empty;
defer f_pairs.deinit(fa);
switch (rand.intRangeAtMost(u8, 0, 9)) {
0 => try f_pairs.append(fa, .{ .key = "a", .value = .{ .int32 = a } }),
1 => {
const v = try bson.copy_value(fa, .{ .doc = &.{.{ .key = "$gt", .value = .{ .int32 = a } }} });
try f_pairs.append(fa, .{ .key = "a", .value = v });
},
2 => try f_pairs.append(fa, .{ .key = "b", .value = .{ .string = s } }),
3 => try f_pairs.append(fa, .{ .key = "tags", .value = .{ .string = s } }),
4 => try f_pairs.append(fa, .{ .key = "d.e", .value = .{ .int32 = a } }),
5 => {
const v = try bson.copy_value(fa, .{ .doc = &.{.{ .key = "$in", .value = .{ .array = &.{ .{ .int32 = a }, .{ .int32 = a + 1 } } } }} });
try f_pairs.append(fa, .{ .key = "a", .value = v });
},
6 => {
const v = try bson.copy_value(fa, .{ .array = &.{
.{ .doc = &.{.{ .key = "a", .value = .{ .int32 = a } }} },
.{ .doc = &.{.{ .key = "b", .value = .{ .string = s } }} },
} });
try f_pairs.append(fa, .{ .key = "$or", .value = v });
},
7 => {
const v = try bson.copy_value(fa, .{ .doc = &.{.{ .key = "$exists", .value = .{ .bool = rand.boolean() } }} });
try f_pairs.append(fa, .{ .key = "tags", .value = v });
},
8 => {
const v = try bson.copy_value(fa, .{ .doc = &.{
.{ .key = "$gte", .value = .{ .int32 = a } },
.{ .key = "$lt", .value = .{ .int32 = a + 3 } },
} });
try f_pairs.append(fa, .{ .key = "a", .value = v });
},
else => try f_pairs.append(fa, .{ .key = "missing", .value = .{ .int32 = a } }),
}
const filter_doc = bson.Document{ .arena = undefined, .pairs = f_pairs.items };
for (bytes_list.items) |bytes| {
var doc = try bson.Document.parse(gpa, bytes);
defer doc.deinit();
const tree = try matches(gpa, &filter_doc, &doc);
const byt = try matches_bytes(gpa, f_pairs.items, bytes);
if (tree != byt) {
std.debug.print("case {d}: filter mismatch: tree={} bytes={}\n", .{
case,
tree,
byt,
});
return error.ByteMatcherMismatch;
}
}
}
}
/// Public single-value operator matcher, used by $pull and $elemMatch.
pub fn value_matches_operator(
gpa: std.mem.Allocator,
op: []const u8,
value: bson.Value,
actual: bson.Value,
) QueryError!bool {
var single: [1]bson.Value = .{actual};
return match_operator(gpa, parse_op(op), value, single[0..], "");
}