Files
MultiforaDB/src/query.zig
Aleksey Shakhmatov 86ae8fa8af style: adopt TigerStyle across src/; add docs/TIGER_STYLE.md
Wrap signatures and long expressions to the 100-column limit and make every
file zig fmt clean. Semantics-preserving throughout: ignoring whitespace and
the trailing commas that wrapping introduces, every file here is byte-identical
to its predecessor, and the one apparent exception is a warning string split
with `++`, which concatenates at comptime to the same bytes.

src/index.zig and src/commands.zig are reformatted in the commits that follow,
because their reformat is interleaved with in-flight changes to them and
separating the two would need the reformat re-derived rather than moved.
2026-08-03 17:08:21 +03:00

1678 lines
64 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 (candidates) |actual| {
if (bson.compare(actual, expected) == .eq) return true;
}
return 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: []const bson.Value,
regex_options: []const u8,
) QueryError!bool {
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 "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..], "");
}