`$[]`, `$[<identifier>]` and `$` stop being refused and start being walked.
A path with a positional segment names a *set* of concrete paths rather than
one -- `y.$[].b` on a two-element array names `y.0.b` and `y.1.b` -- so
`resolve` expands it against the document at hand and every operator then
walks paths it already knew how to walk. Nothing below `resolve` knows a
positional segment exists, which is why `$set`, `$inc`, `$unset`, `$push` and
`$pull` all get it at once.
The static half -- which identifier binds to which array filter, whether a
segment may sit in first position, whether a filter went unused -- depends
only on the update and the filters, so it runs once in `validate` before any
document is touched. The document-dependent half is resolution itself: an
absent or non-array path is a refusal there, because array updates address
what is there rather than creating it, unlike `$set` on a plain path.
Codes and messages measured on mongod 8.3.7, not recalled. Sixteen shapes
were run; the ones that changed what this commit does:
- `$[]` in first position answers the *array filter identifier* message,
not the `$` one -- mongod treats the two spellings as one check.
- an array filter may have several top-level fields as long as they all
name the same identifier: `{i.b: 3, i.c: 1}` is legal, `{i.b: 3, j.b: 1}`
is not. So the check is on the name, not on the count.
- an identifier is `[a-z][a-zA-Z0-9]*`: `aB2` yes, `Ab`, `a_b`, `1x` no.
- `$rename` refuses a positional path on *either* end, with its own message
for each, so it keeps the plain split rather than resolving.
- `$unset` of an element leaves a null in its place rather than shortening
the array -- the same answer `$unset: {"y.0": ""}` already gave.
- a scalar element with more path below it is PathNotViable (28), not a
field to create. Without that check the walk would hand `y.1.b` to
`set_path` and it would replace the `7` at `y.1` with `{b: 9}` -- a
smaller copy of the destruction this whole walk replaced.
One divergence, measured and deliberate: `{"y.b": 3, "y.c": 2}` matches
`[{b: 3, c: 1}, {b: 1, c: 2}]` without either element satisfying both, and
`$` then picks element 1 on mongod and element 0 here. mongod's answer is an
artefact of which predicate last wrote its match position; guessing at it
would be worse than recording it. PLAN §6.
The command handlers still pass neither the query nor any array filters, so
over the wire this is `$[]` working and the other two refusing -- for the
right reason and with the right code, which they did not before. The plumbing
is the next commit.
221/221 unit tests in ReleaseFast and ReleaseSafe, 83/83 fuzz.
Positional corpus 15 -> 23 of 51.
1642 lines
68 KiB
Zig
1642 lines
68 KiB
Zig
//! Update operators: $set, $unset, $inc, $push, $pull, $rename with
|
|
//! dot-path navigation, including MongoDB's three positional forms.
|
|
//! Mutates the document's pairs in place, allocating from the document's
|
|
//! own arena.
|
|
//!
|
|
//! A path with a positional segment names a *set* of concrete paths rather
|
|
//! than one: `y.$[].b` on a two-element array names `y.0.b` and `y.1.b`.
|
|
//! `resolve` turns the former into the latter against the document at hand,
|
|
//! and every operator then walks the concrete paths it already knew how to
|
|
//! walk. Nothing below `resolve` knows a positional segment exists.
|
|
|
|
const std = @import("std");
|
|
const bson = @import("bson.zig");
|
|
const query = @import("query.zig");
|
|
|
|
pub const UpdateError = error{
|
|
ImmutableId,
|
|
InvalidUpdate,
|
|
/// A path segment that is not a field to create: a non-numeric name
|
|
/// applied to an array, or any name applied to a scalar element a
|
|
/// positional segment selected. mongod's `PathNotViable`.
|
|
PathNotViable,
|
|
/// `$`, `$[]` or `$[<identifier>]` as a path's first segment, where there
|
|
/// is no array above it to descend into.
|
|
PositionalFirst,
|
|
/// More than one `$` in one path. Only the first can be resolved: the
|
|
/// query records one matched element per document, not one per level.
|
|
TooManyPositional,
|
|
/// `$[<identifier>]` naming an identifier that no array filter binds.
|
|
NoArrayFilter,
|
|
/// An array filter whose top-level field name is not `[a-z][a-zA-Z0-9]*`.
|
|
BadArrayFilterIdentifier,
|
|
/// An array filter whose identifier no path in the update mentions. A
|
|
/// filter that selects nothing is a typo, so mongod refuses rather than
|
|
/// quietly ignoring it -- and so a `$[i]` misspelt on one side or the
|
|
/// other is caught by whichever half it broke.
|
|
UnusedArrayFilter,
|
|
/// Two array filters binding the same identifier.
|
|
DuplicateArrayFilter,
|
|
/// An array filter with no top-level field name to bind.
|
|
EmptyArrayFilter,
|
|
/// An array filter whose top-level fields name two different identifiers.
|
|
MultipleArrayFilterIdentifiers,
|
|
/// A positional segment whose array is absent from the document. Array
|
|
/// updates address what is there; unlike `$set` on a plain path, they do
|
|
/// not bring it into being.
|
|
ArrayPathRequired,
|
|
/// A positional segment whose path names something that is not an array.
|
|
NotAnArrayPath,
|
|
/// `$` where the query holds no predicate on the array, or none that any
|
|
/// element satisfies. `$` writes *the element the query matched*, so with
|
|
/// no such predicate there is nothing for it to mean.
|
|
NoPositionalMatch,
|
|
/// `$rename` with a positional source or destination. Both ends of a
|
|
/// rename are refused by mongod: a rename moves a field, and a positional
|
|
/// path names an element rather than a field.
|
|
RenameDynamicSource,
|
|
RenameDynamicDestination,
|
|
OutOfMemory,
|
|
};
|
|
|
|
/// What a refusal was about, so the reply can name it instead of saying "bad
|
|
/// update". Mostly borrowed from the update document, which outlives the call;
|
|
/// `ArrayPathRequired` and `NotAnArrayPath` name a *resolved* prefix
|
|
/// (`y.1.c`), which is allocated from the same arena as the document copy.
|
|
pub const Diagnostic = struct {
|
|
path: []const u8 = "",
|
|
segment: []const u8 = "",
|
|
/// A second name, for the one message that mentions two.
|
|
other: []const u8 = "",
|
|
};
|
|
|
|
fn note(diag: ?*Diagnostic, path: []const u8, segment: []const u8) void {
|
|
if (diag) |d| d.* = .{ .path = path, .segment = segment };
|
|
}
|
|
|
|
/// One `arrayFilters` entry: the identifier `$[<identifier>]` spells, and the
|
|
/// predicate that decides which elements it selects.
|
|
///
|
|
/// `used` is written by `validate` as it walks the update's paths, and read
|
|
/// once afterwards -- which is the whole of `UnusedArrayFilter`.
|
|
pub const ArrayFilter = struct {
|
|
/// Filled in by `validate` from `pairs`, not by the caller.
|
|
ident: []const u8 = "",
|
|
/// The predicate, with `ident` still on the front of every key.
|
|
pairs: []const bson.Pair,
|
|
used: bool = false,
|
|
};
|
|
|
|
pub const Options = struct {
|
|
/// Bound to the `$[<identifier>]` segments of the update's paths.
|
|
array_filters: []ArrayFilter = &.{},
|
|
/// The command's query. `$` resolves against it, so an update carrying one
|
|
/// without this is refused rather than guessing at an element.
|
|
query: ?[]const bson.Pair = null,
|
|
diag: ?*Diagnostic = null,
|
|
};
|
|
|
|
const max_path_segments = 16;
|
|
|
|
/// The wrapper field an element is matched under. A predicate written against
|
|
/// an element (`{"i.b": 3}`, `{"y.b": 3}`) becomes one written against
|
|
/// `{elem_key: <element>}`, which is a document the query engine can answer
|
|
/// about without knowing anything about arrays or identifiers.
|
|
const elem_key = "e";
|
|
|
|
/// One segment of a parsed path.
|
|
const Segment = union(enum) {
|
|
literal: []const u8,
|
|
/// `$[]` -- every element.
|
|
all,
|
|
/// `$[<identifier>]` -- the elements its array filter selects, by index
|
|
/// into `Options.array_filters`.
|
|
filtered: usize,
|
|
/// `$` -- the one element the query matched.
|
|
first,
|
|
};
|
|
|
|
const Path = struct {
|
|
text: []const u8,
|
|
segs: [max_path_segments]Segment,
|
|
n: usize,
|
|
};
|
|
|
|
fn is_positional_segment(seg: []const u8) bool {
|
|
if (std.mem.eql(u8, seg, "$")) return true;
|
|
return seg.len >= 3 and std.mem.startsWith(u8, seg, "$[") and seg[seg.len - 1] == ']';
|
|
}
|
|
|
|
fn has_positional(path: []const u8) bool {
|
|
var it = std.mem.splitScalar(u8, path, '.');
|
|
while (it.next()) |seg| if (is_positional_segment(seg)) return true;
|
|
return false;
|
|
}
|
|
|
|
/// mongod's rule for an array filter identifier, measured on 8.3.7: `aB2` is
|
|
/// accepted, `Ab`, `a_b` and `1x` are not.
|
|
fn valid_identifier(ident: []const u8) bool {
|
|
if (ident.len == 0) return false;
|
|
if (ident[0] < 'a' or ident[0] > 'z') return false;
|
|
for (ident[1..]) |c| if (!std.ascii.isAlphanumeric(c)) return false;
|
|
return true;
|
|
}
|
|
|
|
fn find_filter(filters: []const ArrayFilter, ident: []const u8) ?usize {
|
|
for (filters, 0..) |f, i| if (std.mem.eql(u8, f.ident, ident)) return i;
|
|
return null;
|
|
}
|
|
|
|
/// Split a path into segments, classifying the positional ones and binding
|
|
/// each identifier to its array filter.
|
|
///
|
|
/// This is the static half of a positional update: it depends on the update
|
|
/// and the filters, never on a document, so it answers the same way for every
|
|
/// document a multi-update touches.
|
|
fn parse_path(text: []const u8, opts: Options) UpdateError!Path {
|
|
var p: Path = .{ .text = text, .segs = undefined, .n = 0 };
|
|
var dollars: usize = 0;
|
|
var it = std.mem.splitScalar(u8, text, '.');
|
|
while (it.next()) |seg| {
|
|
if (p.n >= max_path_segments) return error.InvalidUpdate;
|
|
p.segs[p.n] = seg: {
|
|
if (!is_positional_segment(seg)) break :seg .{ .literal = seg };
|
|
if (p.n == 0) {
|
|
note(opts.diag, text, seg);
|
|
return error.PositionalFirst;
|
|
}
|
|
if (std.mem.eql(u8, seg, "$")) {
|
|
dollars += 1;
|
|
break :seg .first;
|
|
}
|
|
const ident = seg[2 .. seg.len - 1];
|
|
if (ident.len == 0) break :seg .all;
|
|
const found = find_filter(opts.array_filters, ident) orelse {
|
|
note(opts.diag, text, ident);
|
|
return error.NoArrayFilter;
|
|
};
|
|
opts.array_filters[found].used = true;
|
|
break :seg .{ .filtered = found };
|
|
};
|
|
p.n += 1;
|
|
}
|
|
if (dollars > 1) {
|
|
note(opts.diag, text, "$");
|
|
return error.TooManyPositional;
|
|
}
|
|
return p;
|
|
}
|
|
|
|
/// Everything about an update that can be refused without reading a document:
|
|
/// the array filters, then every path they bind into.
|
|
///
|
|
/// Called by `apply`, and separately by the command handlers *before* they
|
|
/// scan for matches -- an update naming an identifier nothing binds is
|
|
/// refused whether or not it would have matched anything, which is also what
|
|
/// makes `UnusedArrayFilter` observable on a filter that matches no document.
|
|
pub fn validate(update: []const bson.Pair, opts: Options) UpdateError!void {
|
|
// A replacement carries data, not paths, so nothing here applies to it --
|
|
// which is also why mongod ignores `arrayFilters` alongside one, the
|
|
// single case out of seventeen where this server already agreed with it.
|
|
if (is_replacement(update)) return;
|
|
try bind_array_filters(opts);
|
|
for (update) |op| {
|
|
const ops = doc_pairs(op.value) orelse continue;
|
|
const rename = std.mem.eql(u8, op.key, "$rename");
|
|
for (ops) |p| {
|
|
if (rename) {
|
|
if (has_positional(p.key)) {
|
|
note(opts.diag, p.key, "");
|
|
return error.RenameDynamicSource;
|
|
}
|
|
if (p.value == .string and has_positional(p.value.string)) {
|
|
note(opts.diag, p.value.string, "");
|
|
return error.RenameDynamicDestination;
|
|
}
|
|
continue;
|
|
}
|
|
_ = try parse_path(p.key, opts);
|
|
}
|
|
}
|
|
for (opts.array_filters) |f| {
|
|
if (f.used) continue;
|
|
note(opts.diag, "", f.ident);
|
|
return error.UnusedArrayFilter;
|
|
}
|
|
}
|
|
|
|
/// Read each array filter's identifier off its top-level field names.
|
|
///
|
|
/// `{"i.b": 3}` binds `i`; `{"i.b": 3, "i.c": 1}` also binds `i` and is legal;
|
|
/// `{"i.b": 3, "j.b": 1}` names two and is not. Measured, all four.
|
|
fn bind_array_filters(opts: Options) UpdateError!void {
|
|
for (opts.array_filters) |*f| {
|
|
var ident: ?[]const u8 = null;
|
|
for (f.pairs) |p| {
|
|
const name = p.key[0 .. std.mem.indexOfScalar(u8, p.key, '.') orelse p.key.len];
|
|
if (ident) |first| {
|
|
if (std.mem.eql(u8, first, name)) continue;
|
|
if (opts.diag) |d| d.* = .{ .segment = first, .other = name };
|
|
return error.MultipleArrayFilterIdentifiers;
|
|
}
|
|
ident = name;
|
|
}
|
|
const name = ident orelse return error.EmptyArrayFilter;
|
|
if (!valid_identifier(name)) {
|
|
note(opts.diag, "", name);
|
|
return error.BadArrayFilterIdentifier;
|
|
}
|
|
f.ident = name;
|
|
}
|
|
for (opts.array_filters, 0..) |f, i| {
|
|
for (opts.array_filters[i + 1 ..]) |g| {
|
|
if (!std.mem.eql(u8, f.ident, g.ident)) continue;
|
|
note(opts.diag, "", f.ident);
|
|
return error.DuplicateArrayFilter;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Whether an update document is a *replacement* rather than a set of
|
|
/// operators. MongoDB decides on the first field and nothing else: a
|
|
/// `$`-prefixed one means operators. An empty document is a replacement, and a
|
|
/// legal one -- it strips every field but `_id`.
|
|
///
|
|
/// This distinction is the whole of `replaceOne`, `findOneAndReplace` and
|
|
/// `bulkWrite`'s `replaceOne`. Without it `apply` rejected every update whose
|
|
/// first key was not `$`-prefixed, so all three failed with "bad update".
|
|
pub fn is_replacement(pairs: []const bson.Pair) bool {
|
|
if (pairs.len == 0) return true;
|
|
return !is_operator_key(pairs[0].key);
|
|
}
|
|
|
|
fn is_operator_key(key: []const u8) bool {
|
|
return key.len > 0 and key[0] == '$';
|
|
}
|
|
|
|
/// Apply an update document to `doc`: a replacement, or a set of operators.
|
|
pub fn apply(
|
|
doc: *bson.Document,
|
|
update: *const bson.Document,
|
|
opts: Options,
|
|
) UpdateError!void {
|
|
if (is_replacement(update.pairs)) return apply_replacement(doc, update.pairs);
|
|
// Up front rather than at the point of use, because one update names
|
|
// several paths: validating as we walk would refuse the third path having
|
|
// already rewritten what the first two named.
|
|
try validate(update.pairs, opts);
|
|
const arena = doc.arena.allocator();
|
|
var pairs = try copy_to_list(bson.Pair, arena, doc.pairs);
|
|
for (update.pairs) |op| {
|
|
// The first field decided this is an operator update, so a field that
|
|
// is not one is a mixed document -- which MongoDB rejects rather than
|
|
// guessing at.
|
|
if (!is_operator_key(op.key)) return error.InvalidUpdate;
|
|
try apply_operator(arena, &pairs, op.key, op.value, opts);
|
|
}
|
|
doc.pairs = try pairs.toOwnedSlice(arena);
|
|
}
|
|
|
|
/// The concrete paths one update path names in `root`.
|
|
///
|
|
/// A path with no positional segment names itself, and is returned unresolved
|
|
/// -- including when nothing along it exists, because `$set` on a plain path
|
|
/// creates what it needs. A positional segment is different: it addresses
|
|
/// elements that are already there, so an absent or non-array path is a
|
|
/// refusal rather than a creation.
|
|
fn resolve(
|
|
arena: std.mem.Allocator,
|
|
root: []const bson.Pair,
|
|
text: []const u8,
|
|
opts: Options,
|
|
) UpdateError![]const []const []const u8 {
|
|
const path = try parse_path(text, opts);
|
|
var out: std.ArrayListUnmanaged([]const []const u8) = .empty;
|
|
var walk = Walk{ .arena = arena, .opts = opts, .path = &path, .out = &out };
|
|
try walk.descend(.{ .doc = root }, 0);
|
|
return out.items;
|
|
}
|
|
|
|
/// Expands one parsed path into the concrete paths it names, depth first, so
|
|
/// the results come out in document order.
|
|
const Walk = struct {
|
|
arena: std.mem.Allocator,
|
|
opts: Options,
|
|
path: *const Path,
|
|
out: *std.ArrayListUnmanaged([]const []const u8),
|
|
/// The concrete segments chosen so far. Positional ones hold the index
|
|
/// they resolved to, which is what makes `y.1.c` the prefix a refusal
|
|
/// names.
|
|
buf: [max_path_segments][]const u8 = undefined,
|
|
|
|
fn descend(self: *Walk, container: ?bson.Value, i: usize) UpdateError!void {
|
|
if (i == self.path.n) {
|
|
try self.out.append(self.arena, try self.arena.dupe([]const u8, self.buf[0..i]));
|
|
return;
|
|
}
|
|
switch (self.path.segs[i]) {
|
|
.literal => |lit| {
|
|
self.buf[i] = lit;
|
|
try self.descend(child_value(container, lit), i + 1);
|
|
},
|
|
else => try self.spread(container, i),
|
|
}
|
|
}
|
|
|
|
/// A positional segment: one step down, several ways.
|
|
fn spread(self: *Walk, container: ?bson.Value, i: usize) UpdateError!void {
|
|
const value = container orelse {
|
|
note(self.opts.diag, try self.prefix_text(i), "");
|
|
return error.ArrayPathRequired;
|
|
};
|
|
const arr = switch (value) {
|
|
.array => |a| a,
|
|
else => {
|
|
note(self.opts.diag, try self.prefix_text(i), "");
|
|
return error.NotAnArrayPath;
|
|
},
|
|
};
|
|
switch (self.path.segs[i]) {
|
|
.all => for (arr, 0..) |elem, k| try self.element(elem, k, i),
|
|
.filtered => |fi| {
|
|
const f = self.opts.array_filters[fi];
|
|
const pred = try rewrite_keys(self.arena, f.pairs, f.ident.len);
|
|
for (arr, 0..) |elem, k| {
|
|
if (matches_element(self.arena, .{ .wrapped = pred }, elem)) {
|
|
try self.element(elem, k, i);
|
|
}
|
|
}
|
|
},
|
|
.first => {
|
|
const k = try self.first_match(arr, i);
|
|
try self.element(arr[k], k, i);
|
|
},
|
|
.literal => unreachable,
|
|
}
|
|
}
|
|
|
|
fn element(self: *Walk, elem: bson.Value, k: usize, i: usize) UpdateError!void {
|
|
// A scalar element with more path below it is not a field to create.
|
|
// Without this the walk would hand `y.1.b` to `set_path`, which would
|
|
// replace the `7` at `y.1` with `{b: 9}` -- the same class of silent
|
|
// destruction the positional forms themselves used to cause.
|
|
if (i + 1 < self.path.n) switch (self.path.segs[i + 1]) {
|
|
.literal => |lit| switch (elem) {
|
|
.doc, .array => {},
|
|
else => {
|
|
note(self.opts.diag, self.path.text, lit);
|
|
return error.PathNotViable;
|
|
},
|
|
},
|
|
else => {},
|
|
};
|
|
self.buf[i] = try std.fmt.allocPrint(self.arena, "{d}", .{k});
|
|
try self.descend(elem, i + 1);
|
|
}
|
|
|
|
/// The resolved path above segment `i`: `y`, or `y.1.c` under a positional
|
|
/// segment that already chose an element. This is the path a refusal
|
|
/// names, and mongod names the same one.
|
|
fn prefix_text(self: *Walk, i: usize) UpdateError![]const u8 {
|
|
return std.mem.join(self.arena, ".", self.buf[0..i]);
|
|
}
|
|
|
|
/// Which element the query matched, for `$`.
|
|
///
|
|
/// The query is written against the *unresolved* path -- `{"y.b": 3}`
|
|
/// selects an element of `y` however deep a positional segment above it
|
|
/// went -- so the predicates are gathered by the path with its positional
|
|
/// segments elided, and then run against the concrete array here.
|
|
fn first_match(self: *Walk, arr: []const bson.Value, i: usize) UpdateError!usize {
|
|
const q = self.opts.query orelse return self.no_match();
|
|
var preds: std.ArrayListUnmanaged(ElemPred) = .empty;
|
|
try collect_preds(self.arena, q, try self.query_prefix(i), &preds);
|
|
if (preds.items.len == 0) return self.no_match();
|
|
for (arr, 0..) |elem, k| {
|
|
var all = true;
|
|
for (preds.items) |p| {
|
|
if (!matches_element(self.arena, p, elem)) {
|
|
all = false;
|
|
break;
|
|
}
|
|
}
|
|
if (all) return k;
|
|
}
|
|
// Two predicates on one array need not agree on an element, and the
|
|
// document still matched: `{"y.b": 3, "y.c": 2}` matches
|
|
// `[{b: 3, c: 1}, {b: 1, c: 2}]` without either element satisfying
|
|
// both. mongod answers 1 here; this answers 0. Recorded in PLAN §6
|
|
// rather than guessed at -- the rule mongod uses is an artefact of
|
|
// which predicate last wrote its match position.
|
|
for (arr, 0..) |elem, k| {
|
|
for (preds.items) |p| if (matches_element(self.arena, p, elem)) return k;
|
|
}
|
|
return self.no_match();
|
|
}
|
|
|
|
fn no_match(self: *Walk) UpdateError {
|
|
note(self.opts.diag, self.path.text, "$");
|
|
return error.NoPositionalMatch;
|
|
}
|
|
|
|
fn query_prefix(self: *Walk, i: usize) UpdateError![]const u8 {
|
|
var parts: [max_path_segments][]const u8 = undefined;
|
|
var n: usize = 0;
|
|
for (self.path.segs[0..i]) |seg| switch (seg) {
|
|
.literal => |lit| {
|
|
parts[n] = lit;
|
|
n += 1;
|
|
},
|
|
else => {},
|
|
};
|
|
return std.mem.join(self.arena, ".", parts[0..n]);
|
|
}
|
|
};
|
|
|
|
fn child_value(container: ?bson.Value, key: []const u8) ?bson.Value {
|
|
return switch (container orelse return null) {
|
|
.doc => |sub| bson.get_pair(sub, key),
|
|
.array => |arr| blk: {
|
|
const index = parse_index(key) orelse break :blk null;
|
|
break :blk if (index < arr.len) arr[index] else null;
|
|
},
|
|
else => null,
|
|
};
|
|
}
|
|
|
|
/// A predicate about one array element.
|
|
const ElemPred = union(enum) {
|
|
/// Keys rewritten onto `elem_key`, matched against `{e: <element>}`.
|
|
wrapped: []const bson.Pair,
|
|
/// `$elemMatch`'s own document, matched against the element directly.
|
|
direct: []const bson.Pair,
|
|
};
|
|
|
|
fn matches_element(arena: std.mem.Allocator, pred: ElemPred, elem: bson.Value) bool {
|
|
switch (pred) {
|
|
.wrapped => |pairs| {
|
|
const wrapper = [_]bson.Pair{.{ .key = elem_key, .value = elem }};
|
|
return query.matches(
|
|
arena,
|
|
&.{ .arena = undefined, .pairs = pairs },
|
|
&.{ .arena = undefined, .pairs = &wrapper },
|
|
) catch false;
|
|
},
|
|
.direct => |pairs| {
|
|
const sub = switch (elem) {
|
|
.doc => |d| d,
|
|
else => return false,
|
|
};
|
|
return query.matches(
|
|
arena,
|
|
&.{ .arena = undefined, .pairs = pairs },
|
|
&.{ .arena = undefined, .pairs = sub },
|
|
) catch false;
|
|
},
|
|
}
|
|
}
|
|
|
|
/// Re-point a predicate's keys from whatever they were written against onto
|
|
/// `elem_key`: `i.b` with `prefix_len` 1 becomes `e.b`, and `i` becomes `e`.
|
|
fn rewrite_keys(
|
|
arena: std.mem.Allocator,
|
|
pairs: []const bson.Pair,
|
|
prefix_len: usize,
|
|
) UpdateError![]const bson.Pair {
|
|
const out = try arena.alloc(bson.Pair, pairs.len);
|
|
for (pairs, 0..) |p, i| {
|
|
out[i] = .{
|
|
.key = try std.mem.concat(arena, u8, &.{ elem_key, p.key[prefix_len..] }),
|
|
.value = p.value,
|
|
};
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/// The query's predicates about elements of the array at `prefix`.
|
|
///
|
|
/// `$and` is descended into because a driver writes one whenever two
|
|
/// predicates share a field; `$or` is not, because an element satisfying one
|
|
/// branch says nothing about the document having matched through it.
|
|
fn collect_preds(
|
|
arena: std.mem.Allocator,
|
|
q: []const bson.Pair,
|
|
prefix: []const u8,
|
|
out: *std.ArrayListUnmanaged(ElemPred),
|
|
) UpdateError!void {
|
|
for (q) |p| {
|
|
if (std.mem.eql(u8, p.key, "$and")) {
|
|
const branches = switch (p.value) {
|
|
.array => |a| a,
|
|
else => continue,
|
|
};
|
|
for (branches) |b| switch (b) {
|
|
.doc => |sub| try collect_preds(arena, sub, prefix, out),
|
|
else => {},
|
|
};
|
|
continue;
|
|
}
|
|
if (!std.mem.startsWith(u8, p.key, prefix)) continue;
|
|
const rest = p.key[prefix.len..];
|
|
if (rest.len != 0 and rest[0] != '.') continue;
|
|
if (rest.len == 0) {
|
|
if (p.value == .doc) {
|
|
if (bson.get_pair(p.value.doc, "$elemMatch")) |em| {
|
|
if (em == .doc) {
|
|
try out.append(arena, .{ .direct = em.doc });
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
try out.append(arena, .{ .wrapped = try rewrite_keys(arena, &.{p}, prefix.len) });
|
|
}
|
|
}
|
|
|
|
/// Replace every field of `doc` with `replacement`'s, except `_id`.
|
|
///
|
|
/// `_id` is immutable, so it survives and keeps its position at the front (which
|
|
/// is also where MongoDB stores it, and what the `_id_` index descends on). A
|
|
/// replacement carrying an `_id` is allowed only when it is the *same* `_id`;
|
|
/// anything else is an attempt to change a document's identity by rewriting it.
|
|
///
|
|
/// `doc` may legitimately have no `_id` yet: that is the upsert path, where the
|
|
/// caller has seeded the document from the filter's equalities and `insert`
|
|
/// generates an ObjectId afterwards. Then the replacement's own `_id`, if it has
|
|
/// one, is what the new document gets.
|
|
fn apply_replacement(doc: *bson.Document, replacement: []const bson.Pair) UpdateError!void {
|
|
const arena = doc.arena.allocator();
|
|
const old_id = doc.get("_id");
|
|
|
|
var pairs: std.ArrayListUnmanaged(bson.Pair) = .empty;
|
|
errdefer pairs.deinit(arena);
|
|
try pairs.ensureTotalCapacity(arena, replacement.len + 1);
|
|
|
|
if (old_id) |id| {
|
|
try pairs.append(arena, .{
|
|
.key = try arena.dupe(u8, "_id"),
|
|
.value = try bson.copy_value(arena, id),
|
|
});
|
|
}
|
|
for (replacement) |p| {
|
|
// A replacement is data, not instructions. `{$set: {...}}` reaching here
|
|
// would mean the first field was not an operator and a later one was,
|
|
// i.e. a mixed document.
|
|
if (is_operator_key(p.key)) return error.InvalidUpdate;
|
|
if (std.mem.eql(u8, p.key, "_id")) {
|
|
if (old_id) |id| {
|
|
if (bson.compare(id, p.value) != .eq) return error.ImmutableId;
|
|
continue; // already at the front
|
|
}
|
|
// No stored `_id`: this replacement supplies it.
|
|
try pairs.insert(arena, 0, .{
|
|
.key = try arena.dupe(u8, "_id"),
|
|
.value = try bson.copy_value(arena, p.value),
|
|
});
|
|
continue;
|
|
}
|
|
try pairs.append(arena, .{
|
|
.key = try arena.dupe(u8, p.key),
|
|
.value = try bson.copy_value(arena, p.value),
|
|
});
|
|
}
|
|
doc.pairs = try pairs.toOwnedSlice(arena);
|
|
}
|
|
|
|
fn apply_operator(
|
|
arena: std.mem.Allocator,
|
|
pairs: *std.ArrayListUnmanaged(bson.Pair),
|
|
op: []const u8,
|
|
value: bson.Value,
|
|
opts: Options,
|
|
) UpdateError!void {
|
|
const diag = opts.diag;
|
|
if (std.mem.eql(u8, op, "$set")) {
|
|
const ops = doc_pairs(value) orelse return error.InvalidUpdate;
|
|
for (ops) |p| {
|
|
if (std.mem.eql(u8, p.key, "_id")) return error.ImmutableId;
|
|
for (try resolve(arena, pairs.items, p.key, opts)) |segs| {
|
|
try set_path(arena, pairs, segs, try bson.copy_value(arena, p.value), p.key, diag);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
if (std.mem.eql(u8, op, "$unset")) {
|
|
const ops = doc_pairs(value) orelse return error.InvalidUpdate;
|
|
for (ops) |p| {
|
|
for (try resolve(arena, pairs.items, p.key, opts)) |segs| {
|
|
unset_path(arena, pairs, segs);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
if (std.mem.eql(u8, op, "$inc")) {
|
|
const ops = doc_pairs(value) orelse return error.InvalidUpdate;
|
|
for (ops) |p| {
|
|
for (try resolve(arena, pairs.items, p.key, opts)) |segs| {
|
|
const current = get_value(pairs.items, segs) orelse bson.Value{ .int32 = 0 };
|
|
if (!current.is_number() or !p.value.is_number()) return error.InvalidUpdate;
|
|
const sum = try numeric_add(current, p.value);
|
|
try set_path(arena, pairs, segs, sum, p.key, diag);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
if (std.mem.eql(u8, op, "$push")) {
|
|
const ops = doc_pairs(value) orelse return error.InvalidUpdate;
|
|
for (ops) |p| {
|
|
for (try resolve(arena, pairs.items, p.key, opts)) |segs| {
|
|
const current_opt = get_value(pairs.items, segs);
|
|
var items: std.ArrayListUnmanaged(bson.Value) = .empty;
|
|
defer items.deinit(arena);
|
|
if (current_opt) |current| {
|
|
switch (current) {
|
|
.array => |arr| try items.appendSlice(arena, arr),
|
|
.null => {},
|
|
else => return error.InvalidUpdate, // non-array field
|
|
}
|
|
}
|
|
if (p.value == .doc) {
|
|
if (bson.get_pair(p.value.doc, "$each")) |each| {
|
|
const arr = switch (each) {
|
|
.array => |a| a,
|
|
else => return error.InvalidUpdate,
|
|
};
|
|
for (arr) |item| try items.append(arena, try bson.copy_value(arena, item));
|
|
try set_path(arena, pairs, segs, .{ .array = try items.toOwnedSlice(arena) }, p.key, diag);
|
|
continue;
|
|
}
|
|
}
|
|
try items.append(arena, try bson.copy_value(arena, p.value));
|
|
try set_path(arena, pairs, segs, .{ .array = try items.toOwnedSlice(arena) }, p.key, diag);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
if (std.mem.eql(u8, op, "$pull")) {
|
|
const ops = doc_pairs(value) orelse return error.InvalidUpdate;
|
|
for (ops) |p| {
|
|
for (try resolve(arena, pairs.items, p.key, opts)) |segs| {
|
|
const current = get_value(pairs.items, segs) orelse continue;
|
|
const arr = switch (current) {
|
|
.array => |a| a,
|
|
else => return error.InvalidUpdate,
|
|
};
|
|
var items: std.ArrayListUnmanaged(bson.Value) = .empty;
|
|
defer items.deinit(arena);
|
|
for (arr) |elem| {
|
|
if (!pull_matches(arena, p.value, elem)) {
|
|
try items.append(arena, elem);
|
|
}
|
|
}
|
|
try set_path(arena, pairs, segs, .{ .array = try items.toOwnedSlice(arena) }, p.key, diag);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
// `$rename` alone keeps the plain split: `validate` has already refused a
|
|
// positional path on either end of it, which is what mongod does too.
|
|
if (std.mem.eql(u8, op, "$rename")) {
|
|
const ops = doc_pairs(value) orelse return error.InvalidUpdate;
|
|
for (ops) |p| {
|
|
if (p.value != .string) return error.InvalidUpdate;
|
|
if (std.mem.eql(u8, p.key, "_id") or std.mem.eql(u8, p.value.string, "_id")) return error.ImmutableId;
|
|
var old_segs: [max_path_segments][]const u8 = undefined;
|
|
const old_n = split_path(p.key, &old_segs) orelse return error.InvalidUpdate;
|
|
const v = get_value(pairs.items, old_segs[0..old_n]) orelse continue; // no-op when absent
|
|
unset_path(arena, pairs, old_segs[0..old_n]);
|
|
var new_segs: [max_path_segments][]const u8 = undefined;
|
|
const new_n = split_path(p.value.string, &new_segs) orelse return error.InvalidUpdate;
|
|
try set_path(arena, pairs, new_segs[0..new_n], v, p.value.string, diag);
|
|
}
|
|
return;
|
|
}
|
|
return error.InvalidUpdate;
|
|
}
|
|
|
|
fn doc_pairs(v: bson.Value) ?[]const bson.Pair {
|
|
return switch (v) {
|
|
.doc => |pairs| pairs,
|
|
else => null,
|
|
};
|
|
}
|
|
|
|
fn split_path(path: []const u8, out: *[max_path_segments][]const u8) ?usize {
|
|
var n: usize = 0;
|
|
var it = std.mem.splitScalar(u8, path, '.');
|
|
while (it.next()) |seg| {
|
|
if (n >= max_path_segments) return null;
|
|
out[n] = seg;
|
|
n += 1;
|
|
}
|
|
return n;
|
|
}
|
|
|
|
fn parse_index(seg: []const u8) ?usize {
|
|
return std.fmt.parseInt(usize, seg, 10) catch null;
|
|
}
|
|
|
|
/// Shallow-copy a slice into a growable list backed by `arena`.
|
|
fn copy_to_list(
|
|
comptime T: type,
|
|
arena: std.mem.Allocator,
|
|
items: []const T,
|
|
) UpdateError!std.ArrayListUnmanaged(T) {
|
|
var out: std.ArrayListUnmanaged(T) = .empty;
|
|
errdefer out.deinit(arena);
|
|
try out.appendSlice(arena, items);
|
|
return out;
|
|
}
|
|
|
|
const find_pair = bson.get_pair_index;
|
|
|
|
fn get_value(pairs: []const bson.Pair, segs: []const []const u8) ?bson.Value {
|
|
const idx = find_pair(pairs, segs[0]) orelse return null;
|
|
if (segs.len == 1) return pairs[idx].value;
|
|
return switch (pairs[idx].value) {
|
|
.doc => |sub| get_value(sub, segs[1..]),
|
|
.array => |arr| blk: {
|
|
const index = parse_index(segs[1]) orelse break :blk null;
|
|
if (index >= arr.len) break :blk null;
|
|
if (segs.len == 2) break :blk arr[index];
|
|
break :blk switch (arr[index]) {
|
|
.doc => |sub| get_value(sub, segs[2..]),
|
|
else => null,
|
|
};
|
|
},
|
|
else => null,
|
|
};
|
|
}
|
|
|
|
fn set_path(
|
|
arena: std.mem.Allocator,
|
|
pairs: *std.ArrayListUnmanaged(bson.Pair),
|
|
segs: []const []const u8,
|
|
value: bson.Value,
|
|
path: []const u8,
|
|
diag: ?*Diagnostic,
|
|
) UpdateError!void {
|
|
if (segs.len == 1) {
|
|
if (find_pair(pairs.items, segs[0])) |idx| {
|
|
pairs.items[idx].value = value;
|
|
} else {
|
|
try pairs.append(arena, .{ .key = try arena.dupe(u8, segs[0]), .value = value });
|
|
}
|
|
return;
|
|
}
|
|
const idx = find_pair(pairs.items, segs[0]) orelse {
|
|
const is_array = parse_index(segs[1]) != null;
|
|
try pairs.append(arena, .{ .key = try arena.dupe(u8, segs[0]), .value = if (is_array) .{ .array = &.{} } else .{ .doc = &.{} } });
|
|
return set_path(arena, pairs, segs, value, path, diag);
|
|
};
|
|
switch (pairs.items[idx].value) {
|
|
.doc => |sub| {
|
|
var sub_pairs = try copy_to_list(bson.Pair, arena, sub);
|
|
defer sub_pairs.deinit(arena);
|
|
try set_path(arena, &sub_pairs, segs[1..], value, path, diag);
|
|
pairs.items[idx].value = .{ .doc = try sub_pairs.toOwnedSlice(arena) };
|
|
},
|
|
.array => |arr| {
|
|
const index = parse_index(segs[1]) orelse {
|
|
// A non-numeric segment under an array is never a field to
|
|
// create. This branch used to read "treat as non-array:
|
|
// replace with a doc" and did exactly that -- `y.nope.b`
|
|
// turned `y: [{b: 3}]` into `y: {nope: {b: 2}}`, discarding
|
|
// every element and answering ok: 1. mongod refuses with
|
|
// PathNotViable and leaves the document alone.
|
|
//
|
|
// The positional spellings took this same branch and are the
|
|
// reason it was found; they are refused earlier, by
|
|
// `reject_positional`. What reaches here is the rest of the
|
|
// class: a plain field name, and a `$`-prefixed one that is
|
|
// not positional.
|
|
note(diag, path, segs[1]);
|
|
return error.PathNotViable;
|
|
};
|
|
var items = try copy_to_list(bson.Value, arena, arr);
|
|
defer items.deinit(arena);
|
|
if (index >= items.items.len) {
|
|
try items.appendNTimes(arena, .null, index + 1 - items.items.len);
|
|
}
|
|
if (segs.len == 2) {
|
|
items.items[index] = value;
|
|
} else {
|
|
switch (items.items[index]) {
|
|
.doc => |sub| {
|
|
var sub_pairs = try copy_to_list(bson.Pair, arena, sub);
|
|
defer sub_pairs.deinit(arena);
|
|
try set_path(arena, &sub_pairs, segs[2..], value, path, diag);
|
|
items.items[index] = .{ .doc = try sub_pairs.toOwnedSlice(arena) };
|
|
},
|
|
else => {
|
|
var sub_pairs: std.ArrayListUnmanaged(bson.Pair) = .empty;
|
|
defer sub_pairs.deinit(arena);
|
|
try set_path(arena, &sub_pairs, segs[2..], value, path, diag);
|
|
items.items[index] = .{ .doc = try sub_pairs.toOwnedSlice(arena) };
|
|
},
|
|
}
|
|
}
|
|
pairs.items[idx].value = .{ .array = try items.toOwnedSlice(arena) };
|
|
},
|
|
else => {
|
|
var sub_pairs: std.ArrayListUnmanaged(bson.Pair) = .empty;
|
|
defer sub_pairs.deinit(arena);
|
|
try set_path(arena, &sub_pairs, segs[1..], value, path, diag);
|
|
pairs.items[idx].value = .{ .doc = try sub_pairs.toOwnedSlice(arena) };
|
|
},
|
|
}
|
|
}
|
|
|
|
fn unset_path(
|
|
arena: std.mem.Allocator,
|
|
pairs: *std.ArrayListUnmanaged(bson.Pair),
|
|
segs: []const []const u8,
|
|
) void {
|
|
if (segs.len == 1) {
|
|
if (find_pair(pairs.items, segs[0])) |idx| {
|
|
_ = pairs.orderedRemove(idx);
|
|
}
|
|
return;
|
|
}
|
|
const idx = find_pair(pairs.items, segs[0]) orelse return;
|
|
switch (pairs.items[idx].value) {
|
|
.doc => |sub| {
|
|
var sub_pairs = copy_to_list(bson.Pair, arena, sub) catch return;
|
|
unset_path(arena, &sub_pairs, segs[1..]);
|
|
pairs.items[idx].value = .{ .doc = sub_pairs.items };
|
|
},
|
|
// Unsetting an element leaves a null in its place rather than
|
|
// shortening the array: measured on both `$unset: {"y.0": ""}` and
|
|
// `$unset: {"y.$[]": ""}`, which is the same rule reached two ways.
|
|
.array => |arr| {
|
|
const index = parse_index(segs[1]) orelse return;
|
|
if (index >= arr.len) return;
|
|
var items = copy_to_list(bson.Value, arena, arr) catch return;
|
|
if (segs.len == 2) {
|
|
items.items[index] = .null;
|
|
} else switch (items.items[index]) {
|
|
.doc => |sub| {
|
|
var sub_pairs = copy_to_list(bson.Pair, arena, sub) catch return;
|
|
unset_path(arena, &sub_pairs, segs[2..]);
|
|
items.items[index] = .{ .doc = sub_pairs.items };
|
|
},
|
|
else => return,
|
|
}
|
|
pairs.items[idx].value = .{ .array = items.items };
|
|
},
|
|
else => {},
|
|
}
|
|
}
|
|
|
|
fn pull_matches(arena: std.mem.Allocator, condition: bson.Value, elem: bson.Value) bool {
|
|
switch (condition) {
|
|
.doc => |cond_pairs| {
|
|
const elem_doc = switch (elem) {
|
|
.doc => |pairs| pairs,
|
|
else => return false,
|
|
};
|
|
if (query.all_operator_keys(cond_pairs)) {
|
|
// Operator condition against the element's value at each
|
|
// operator's field — treat element doc as the doc.
|
|
var ok = true;
|
|
for (cond_pairs) |p| {
|
|
const actuals = bson.get_pair(elem_doc, p.key[1..]);
|
|
const a: bson.Value = actuals orelse .null;
|
|
if (!(query.value_matches_operator(arena, p.key, p.value, a) catch false)) ok = false;
|
|
}
|
|
return ok;
|
|
}
|
|
return (query.matches(arena, &.{ .arena = undefined, .pairs = cond_pairs }, &.{ .arena = undefined, .pairs = elem_doc }) catch false);
|
|
},
|
|
else => return bson.compare(condition, elem) == .eq,
|
|
}
|
|
}
|
|
|
|
fn numeric_add(a: bson.Value, b: bson.Value) UpdateError!bson.Value {
|
|
if (a == .double or b == .double) {
|
|
const sum: f64 = @floatCast(a.as_f128() + b.as_f128());
|
|
return .{ .double = sum };
|
|
}
|
|
if (a == .int64 or b == .int64) {
|
|
const av: i64 = switch (a) {
|
|
.int32 => |i| i,
|
|
.int64 => |i| i,
|
|
else => unreachable,
|
|
};
|
|
const bv: i64 = switch (b) {
|
|
.int32 => |i| i,
|
|
.int64 => |i| i,
|
|
else => unreachable,
|
|
};
|
|
const sum = std.math.add(i64, av, bv) catch return error.InvalidUpdate;
|
|
return .{ .int64 = sum };
|
|
}
|
|
const sum: i64 = @as(i64, a.int32) + b.int32;
|
|
if (sum >= std.math.minInt(i32) and sum <= std.math.maxInt(i32)) {
|
|
return .{ .int32 = @intCast(sum) };
|
|
}
|
|
return .{ .int64 = sum };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const testing = std.testing;
|
|
|
|
fn doc_of(pairs: []const bson.Pair) bson.Document {
|
|
return .{ .arena = undefined, .pairs = pairs };
|
|
}
|
|
|
|
test "$set, $inc, $unset, $rename" {
|
|
var doc = bson.Document{ .arena = std.heap.ArenaAllocator.init(testing.allocator), .pairs = &.{} };
|
|
defer doc.arena.deinit();
|
|
doc.pairs = try doc.arena.allocator().dupe(bson.Pair, &.{
|
|
.{ .key = "a", .value = .{ .int32 = 1 } },
|
|
.{ .key = "user", .value = .{ .doc = &.{
|
|
.{ .key = "name", .value = .{ .string = "bob" } },
|
|
.{ .key = "age", .value = .{ .int32 = 30 } },
|
|
} } },
|
|
.{ .key = "gone", .value = .{ .int32 = 9 } },
|
|
});
|
|
|
|
try apply(&doc, &doc_of(&.{
|
|
.{ .key = "$set", .value = .{ .doc = &.{
|
|
.{ .key = "user.name", .value = .{ .string = "alice" } },
|
|
.{ .key = "user.city", .value = .{ .string = "NYC" } },
|
|
.{ .key = "new", .value = .{ .int32 = 5 } },
|
|
} } },
|
|
.{ .key = "$inc", .value = .{ .doc = &.{.{ .key = "user.age", .value = .{ .int32 = 2 } }} } },
|
|
.{ .key = "$unset", .value = .{ .doc = &.{.{ .key = "gone", .value = .{ .string = "" } }} } },
|
|
.{ .key = "$rename", .value = .{ .doc = &.{.{ .key = "new", .value = .{ .string = "renamed" } }} } },
|
|
}), .{});
|
|
|
|
const user = bson.get_pair(doc.pairs, "user").?;
|
|
try testing.expectEqualStrings("alice", user.doc[0].value.string);
|
|
try testing.expectEqual(@as(i64, 32), bson.get_pair(user.doc, "age").?.int32);
|
|
try testing.expectEqualStrings("NYC", bson.get_pair(user.doc, "city").?.string);
|
|
try testing.expect(bson.get_pair(doc.pairs, "gone") == null);
|
|
try testing.expect(bson.get_pair(doc.pairs, "new") == null);
|
|
try testing.expectEqual(@as(i64, 5), bson.get_pair(doc.pairs, "renamed").?.int32);
|
|
}
|
|
|
|
test "$push and $pull" {
|
|
var doc = bson.Document{ .arena = std.heap.ArenaAllocator.init(testing.allocator), .pairs = &.{} };
|
|
defer doc.arena.deinit();
|
|
doc.pairs = try doc.arena.allocator().dupe(bson.Pair, &.{
|
|
.{ .key = "tags", .value = .{ .array = &.{ .{ .string = "a" }, .{ .string = "b" } } } },
|
|
});
|
|
|
|
try apply(&doc, &doc_of(&.{
|
|
.{ .key = "$push", .value = .{ .doc = &.{.{ .key = "tags", .value = .{ .string = "c" } }} } },
|
|
}), .{});
|
|
try testing.expectEqual(@as(usize, 3), bson.get_pair(doc.pairs, "tags").?.array.len);
|
|
try testing.expectEqualStrings("c", bson.get_pair(doc.pairs, "tags").?.array[2].string);
|
|
|
|
try apply(&doc, &doc_of(&.{
|
|
.{ .key = "$pull", .value = .{ .doc = &.{.{ .key = "tags", .value = .{ .string = "b" } }} } },
|
|
}), .{});
|
|
const tags = bson.get_pair(doc.pairs, "tags").?.array;
|
|
try testing.expectEqual(@as(usize, 2), tags.len);
|
|
try testing.expectEqualStrings("a", tags[0].string);
|
|
try testing.expectEqualStrings("c", tags[1].string);
|
|
|
|
try apply(&doc, &doc_of(&.{
|
|
.{ .key = "$push", .value = .{ .doc = &.{.{ .key = "tags", .value = .{ .doc = &.{.{ .key = "$each", .value = .{ .array = &.{ .{ .string = "x" }, .{ .string = "y" } } } }} } }} } },
|
|
}), .{});
|
|
try testing.expectEqual(@as(usize, 4), bson.get_pair(doc.pairs, "tags").?.array.len);
|
|
}
|
|
|
|
test "$set nested creation and _id protection" {
|
|
var doc = bson.Document{ .arena = std.heap.ArenaAllocator.init(testing.allocator), .pairs = &.{} };
|
|
defer doc.arena.deinit();
|
|
doc.pairs = try doc.arena.allocator().dupe(bson.Pair, &.{
|
|
.{ .key = "_id", .value = .{ .int32 = 1 } },
|
|
});
|
|
|
|
try apply(&doc, &doc_of(&.{
|
|
.{ .key = "$set", .value = .{ .doc = &.{
|
|
.{ .key = "a.b.c", .value = .{ .int32 = 42 } },
|
|
.{ .key = "arr.1", .value = .{ .string = "x" } },
|
|
} } },
|
|
}), .{});
|
|
const a = bson.get_pair(doc.pairs, "a").?;
|
|
const b = bson.get_pair(a.doc, "b").?;
|
|
try testing.expectEqual(@as(i64, 42), bson.get_pair(b.doc, "c").?.int32);
|
|
const arr = bson.get_pair(doc.pairs, "arr").?.array;
|
|
try testing.expectEqual(@as(usize, 2), arr.len);
|
|
try testing.expectEqualStrings("x", arr[1].string);
|
|
|
|
try testing.expectError(error.ImmutableId, apply(&doc, &doc_of(&.{
|
|
.{ .key = "$set", .value = .{ .doc = &.{.{ .key = "_id", .value = .{ .int32 = 2 } }} } },
|
|
}), .{}));
|
|
}
|
|
|
|
test "a replacement keeps _id and drops every other field" {
|
|
// Mutation check: seed `apply_replacement`'s list from `doc.pairs` (as the
|
|
// operator path does) instead of starting empty. Red -- `gone` survives, and
|
|
// a replacement that does not remove fields is not a replacement.
|
|
var doc = bson.Document{ .arena = std.heap.ArenaAllocator.init(testing.allocator), .pairs = &.{} };
|
|
defer doc.arena.deinit();
|
|
doc.pairs = try doc.arena.allocator().dupe(bson.Pair, &.{
|
|
.{ .key = "_id", .value = .{ .int32 = 7 } },
|
|
.{ .key = "gone", .value = .{ .int32 = 1 } },
|
|
.{ .key = "also_gone", .value = .{ .string = "x" } },
|
|
});
|
|
|
|
try apply(&doc, &doc_of(&.{
|
|
.{ .key = "fresh", .value = .{ .int32 = 42 } },
|
|
}), .{});
|
|
|
|
try testing.expectEqual(@as(usize, 2), doc.pairs.len);
|
|
// _id survives, and stays at the front where it is stored.
|
|
try testing.expectEqualStrings("_id", doc.pairs[0].key);
|
|
try testing.expectEqual(@as(i64, 7), doc.pairs[0].value.int32);
|
|
try testing.expectEqual(@as(i64, 42), bson.get_pair(doc.pairs, "fresh").?.int32);
|
|
try testing.expect(bson.get_pair(doc.pairs, "gone") == null);
|
|
try testing.expect(bson.get_pair(doc.pairs, "also_gone") == null);
|
|
}
|
|
|
|
test "an empty replacement leaves a document holding only its _id" {
|
|
var doc = bson.Document{ .arena = std.heap.ArenaAllocator.init(testing.allocator), .pairs = &.{} };
|
|
defer doc.arena.deinit();
|
|
doc.pairs = try doc.arena.allocator().dupe(bson.Pair, &.{
|
|
.{ .key = "_id", .value = .{ .int32 = 7 } },
|
|
.{ .key = "a", .value = .{ .int32 = 1 } },
|
|
});
|
|
|
|
// Legal, and the reason `is_replacement` treats an empty document as one
|
|
// rather than as a no-op set of operators.
|
|
try apply(&doc, &doc_of(&.{}), .{});
|
|
try testing.expectEqual(@as(usize, 1), doc.pairs.len);
|
|
try testing.expectEqualStrings("_id", doc.pairs[0].key);
|
|
}
|
|
|
|
test "a replacement may repeat the _id it is replacing, but not change it" {
|
|
// Mutation check: drop the `bson.compare` in `apply_replacement`. Red on the
|
|
// second half -- a replacement would silently rewrite a document's identity,
|
|
// and since the `_id_` index is keyed on it, the stored entry and the stored
|
|
// document would disagree.
|
|
var doc = bson.Document{ .arena = std.heap.ArenaAllocator.init(testing.allocator), .pairs = &.{} };
|
|
defer doc.arena.deinit();
|
|
const original = [_]bson.Pair{
|
|
.{ .key = "_id", .value = .{ .int32 = 7 } },
|
|
.{ .key = "a", .value = .{ .int32 = 1 } },
|
|
};
|
|
doc.pairs = try doc.arena.allocator().dupe(bson.Pair, &original);
|
|
|
|
// The same _id, restated: accepted.
|
|
try apply(&doc, &doc_of(&.{
|
|
.{ .key = "_id", .value = .{ .int32 = 7 } },
|
|
.{ .key = "b", .value = .{ .int32 = 2 } },
|
|
}), .{});
|
|
try testing.expectEqual(@as(i64, 7), doc.pairs[0].value.int32);
|
|
try testing.expectEqual(@as(i64, 2), bson.get_pair(doc.pairs, "b").?.int32);
|
|
// And only once, not twice.
|
|
try testing.expectEqual(@as(usize, 2), doc.pairs.len);
|
|
|
|
// A different _id: refused.
|
|
try testing.expectError(error.ImmutableId, apply(&doc, &doc_of(&.{
|
|
.{ .key = "_id", .value = .{ .int32 = 8 } },
|
|
}), .{}));
|
|
|
|
// Equal across numeric types is the same _id, matching the canonical key
|
|
// encoding the _id_ index descends on.
|
|
try apply(&doc, &doc_of(&.{
|
|
.{ .key = "_id", .value = .{ .double = 7.0 } },
|
|
.{ .key = "c", .value = .{ .int32 = 3 } },
|
|
}), .{});
|
|
try testing.expectEqual(@as(i64, 3), bson.get_pair(doc.pairs, "c").?.int32);
|
|
}
|
|
|
|
test "a replacement supplies the _id when the document has none" {
|
|
// The upsert path: the caller seeds a document from the filter's equalities
|
|
// and the replacement carries the _id.
|
|
//
|
|
// Mutation check: make the `old_id == null` arm `continue` without adding the
|
|
// pair. Red -- the upserted document loses the _id the client asked for and
|
|
// gets a generated ObjectId instead, so a retried upsert inserts twice.
|
|
var doc = bson.Document{ .arena = std.heap.ArenaAllocator.init(testing.allocator), .pairs = &.{} };
|
|
defer doc.arena.deinit();
|
|
|
|
try apply(&doc, &doc_of(&.{
|
|
.{ .key = "a", .value = .{ .int32 = 1 } },
|
|
.{ .key = "_id", .value = .{ .int32 = 99 } },
|
|
}), .{});
|
|
|
|
try testing.expectEqual(@as(usize, 2), doc.pairs.len);
|
|
try testing.expectEqualStrings("_id", doc.pairs[0].key);
|
|
try testing.expectEqual(@as(i64, 99), doc.pairs[0].value.int32);
|
|
}
|
|
|
|
test "a mixed update document is refused from either side" {
|
|
// Mutation check: drop the `is_operator_key` guard in `apply`'s operator loop
|
|
// (first case) or in `apply_replacement`'s loop (second). Each leaves one
|
|
// half green and the other red.
|
|
var doc = bson.Document{ .arena = std.heap.ArenaAllocator.init(testing.allocator), .pairs = &.{} };
|
|
defer doc.arena.deinit();
|
|
|
|
// Starts with an operator, so operators are expected throughout.
|
|
try testing.expectError(error.InvalidUpdate, apply(&doc, &doc_of(&.{
|
|
.{ .key = "$set", .value = .{ .doc = &.{.{ .key = "a", .value = .{ .int32 = 1 } }} } },
|
|
.{ .key = "plain", .value = .{ .int32 = 1 } },
|
|
}), .{}));
|
|
|
|
// Starts with data, so it is a replacement and an operator has no meaning.
|
|
try testing.expectError(error.InvalidUpdate, apply(&doc, &doc_of(&.{
|
|
.{ .key = "plain", .value = .{ .int32 = 1 } },
|
|
.{ .key = "$set", .value = .{ .doc = &.{.{ .key = "a", .value = .{ .int32 = 1 } }} } },
|
|
}), .{}));
|
|
}
|
|
|
|
test "is_replacement decides on the first field only" {
|
|
try testing.expect(is_replacement(&.{}));
|
|
try testing.expect(is_replacement(&.{.{ .key = "a", .value = .{ .int32 = 1 } }}));
|
|
try testing.expect(!is_replacement(&.{.{ .key = "$set", .value = .{ .doc = &.{} } }}));
|
|
// An empty key cannot be an operator, so it reads as data -- and the
|
|
// replacement path then stores it, which is what MongoDB does with it.
|
|
try testing.expect(is_replacement(&.{.{ .key = "", .value = .{ .int32 = 1 } }}));
|
|
}
|
|
|
|
fn doc_with(gpa: std.mem.Allocator, pairs: []const bson.Pair) !bson.Document {
|
|
var doc = bson.Document{ .arena = std.heap.ArenaAllocator.init(gpa), .pairs = &.{} };
|
|
doc.pairs = try doc.arena.allocator().dupe(bson.Pair, pairs);
|
|
return doc;
|
|
}
|
|
|
|
/// A document with one array field, rebuilt per case so a refusal can be
|
|
/// checked against untouched bytes.
|
|
fn array_doc(arena: std.mem.Allocator) !bson.Document {
|
|
return doc_with(arena, &.{
|
|
.{ .key = "y", .value = .{ .array = &.{
|
|
.{ .doc = &.{.{ .key = "b", .value = .{ .int32 = 3 } }} },
|
|
.{ .doc = &.{.{ .key = "b", .value = .{ .int32 = 1 } }} },
|
|
} } },
|
|
});
|
|
}
|
|
|
|
/// `$set` of one path, the shape most of the cases below need. Owns its two
|
|
/// pairs: a helper *returning* the document would hand back a slice of its own
|
|
/// dead frame.
|
|
const SetOne = struct {
|
|
inner: [1]bson.Pair,
|
|
outer: [1]bson.Pair = undefined,
|
|
|
|
fn init(path: []const u8, value: bson.Value) SetOne {
|
|
return .{ .inner = .{.{ .key = path, .value = value }} };
|
|
}
|
|
|
|
fn doc(self: *SetOne) bson.Document {
|
|
self.outer = .{.{ .key = "$set", .value = .{ .doc = &self.inner } }};
|
|
return doc_of(&self.outer);
|
|
}
|
|
};
|
|
|
|
fn apply_set(
|
|
doc: *bson.Document,
|
|
path: []const u8,
|
|
value: bson.Value,
|
|
opts: Options,
|
|
) UpdateError!void {
|
|
var u = SetOne.init(path, value);
|
|
return apply(doc, &u.doc(), opts);
|
|
}
|
|
|
|
/// The `b` of `y[k]`, which is what most of these updates move.
|
|
fn y_b(doc: *const bson.Document, k: usize) !i32 {
|
|
const y = doc.get("y") orelse return error.TestUnexpectedResult;
|
|
if (y != .array or k >= y.array.len) return error.TestUnexpectedResult;
|
|
const elem = y.array[k];
|
|
if (elem != .doc) return error.TestUnexpectedResult;
|
|
return (bson.get_pair(elem.doc, "b") orelse return error.TestUnexpectedResult).int32;
|
|
}
|
|
|
|
fn expect_y_untouched(doc: *const bson.Document) !void {
|
|
const y = doc.get("y") orelse return error.TestUnexpectedResult;
|
|
// The array is still an array. Before this refusal existed it was a
|
|
// document keyed by the path segment's literal text, and everything in it
|
|
// was gone.
|
|
try testing.expect(y == .array);
|
|
try testing.expectEqual(@as(usize, 2), y.array.len);
|
|
try testing.expectEqual(@as(i32, 3), y.array[0].doc[0].value.int32);
|
|
try testing.expectEqual(@as(i32, 1), y.array[1].doc[0].value.int32);
|
|
}
|
|
|
|
test "$[] writes every element of the array" {
|
|
// The all-positional operator, which the pinned crud corpus does not
|
|
// contain a single case of. Mutation check: make `spread`'s `.all` arm
|
|
// stop after the first element and the second half goes red.
|
|
var doc = try array_doc(testing.allocator);
|
|
defer doc.arena.deinit();
|
|
try apply_set(&doc, "y.$[].b", .{ .int32 = 9 }, .{});
|
|
try testing.expectEqual(@as(i32, 9), try y_b(&doc, 0));
|
|
try testing.expectEqual(@as(i32, 9), try y_b(&doc, 1));
|
|
}
|
|
|
|
test "every operator goes through a positional segment, not just $set" {
|
|
// The destruction this replaced was below the operator, in the shared path
|
|
// walk: `$inc` through `$[]` stored its operand instead of incrementing.
|
|
// So the walk has to be below the operator too.
|
|
var doc = try array_doc(testing.allocator);
|
|
defer doc.arena.deinit();
|
|
try apply(&doc, &doc_of(&.{
|
|
.{ .key = "$inc", .value = .{ .doc = &.{.{ .key = "y.$[].b", .value = .{ .int32 = 10 } }} } },
|
|
}), .{});
|
|
try testing.expectEqual(@as(i32, 13), try y_b(&doc, 0));
|
|
try testing.expectEqual(@as(i32, 11), try y_b(&doc, 1));
|
|
|
|
// `$unset` through an element removes the field; `$unset` *of* an element
|
|
// leaves a null in its place rather than shortening the array.
|
|
try apply(&doc, &doc_of(&.{
|
|
.{ .key = "$unset", .value = .{ .doc = &.{.{ .key = "y.$[].b", .value = .{ .string = "" } }} } },
|
|
}), .{});
|
|
try testing.expectEqual(@as(usize, 0), doc.get("y").?.array[0].doc.len);
|
|
try apply(&doc, &doc_of(&.{
|
|
.{ .key = "$unset", .value = .{ .doc = &.{.{ .key = "y.$[]", .value = .{ .string = "" } }} } },
|
|
}), .{});
|
|
try testing.expect(doc.get("y").?.array[0] == .null);
|
|
try testing.expect(doc.get("y").?.array[1] == .null);
|
|
}
|
|
|
|
test "$[] over an empty array writes nothing at all" {
|
|
// Zero concrete paths, so the operator never runs -- which is how the
|
|
// document comes back byte-identical and the reply says modified 0.
|
|
var doc = try doc_with(testing.allocator, &.{
|
|
.{ .key = "y", .value = .{ .array = &.{} } },
|
|
});
|
|
defer doc.arena.deinit();
|
|
try apply_set(&doc, "y.$[].b", .{ .int32 = 9 }, .{});
|
|
try testing.expectEqual(@as(usize, 0), doc.get("y").?.array.len);
|
|
}
|
|
|
|
test "nested $[] is a cross product over both levels" {
|
|
var doc = try doc_with(testing.allocator, &.{
|
|
.{ .key = "y", .value = .{ .array = &.{
|
|
.{ .doc = &.{.{ .key = "c", .value = .{ .array = &.{
|
|
.{ .doc = &.{.{ .key = "d", .value = .{ .int32 = 1 } }} },
|
|
.{ .doc = &.{.{ .key = "d", .value = .{ .int32 = 2 } }} },
|
|
} } }} },
|
|
.{ .doc = &.{.{ .key = "c", .value = .{ .array = &.{
|
|
.{ .doc = &.{.{ .key = "d", .value = .{ .int32 = 3 } }} },
|
|
} } }} },
|
|
} } },
|
|
});
|
|
defer doc.arena.deinit();
|
|
try apply_set(&doc, "y.$[].c.$[].d", .{ .int32 = 0 }, .{});
|
|
const y = doc.get("y").?.array;
|
|
for (y) |outer| {
|
|
for (outer.doc[0].value.array) |inner| {
|
|
try testing.expectEqual(@as(i32, 0), inner.doc[0].value.int32);
|
|
}
|
|
}
|
|
}
|
|
|
|
test "an array update addresses an array that is there, and refuses one that is not" {
|
|
// The difference between a positional segment and a plain one: `$set` on
|
|
// `a.b.c` creates what it needs, and this does not. Both refusals are code
|
|
// 2 on mongod, with different text, so they are different errors here.
|
|
var missing = try doc_with(testing.allocator, &.{.{ .key = "z", .value = .{ .int32 = 1 } }});
|
|
defer missing.arena.deinit();
|
|
var diag: Diagnostic = .{};
|
|
try testing.expectError(
|
|
error.ArrayPathRequired,
|
|
apply_set(&missing, "y.$[].b", .{ .int32 = 9 }, .{ .diag = &diag }),
|
|
);
|
|
try testing.expectEqualStrings("y", diag.path);
|
|
|
|
var scalar = try doc_with(testing.allocator, &.{.{ .key = "y", .value = .{ .int32 = 5 } }});
|
|
defer scalar.arena.deinit();
|
|
try testing.expectError(
|
|
error.NotAnArrayPath,
|
|
apply_set(&scalar, "y.$[].b", .{ .int32 = 9 }, .{ .diag = &diag }),
|
|
);
|
|
try testing.expectEqualStrings("y", diag.path);
|
|
|
|
// Under a positional segment the refusal names the *resolved* prefix, so
|
|
// it says which element was missing the array. mongod names the same one.
|
|
var nested = try doc_with(testing.allocator, &.{
|
|
.{ .key = "y", .value = .{ .array = &.{
|
|
.{ .doc = &.{.{ .key = "c", .value = .{ .array = &.{} } }} },
|
|
.{ .doc = &.{.{ .key = "b", .value = .{ .int32 = 2 } }} },
|
|
} } },
|
|
});
|
|
defer nested.arena.deinit();
|
|
try testing.expectError(
|
|
error.ArrayPathRequired,
|
|
apply_set(&nested, "y.$[].c.$[].d", .{ .int32 = 0 }, .{ .diag = &diag }),
|
|
);
|
|
try testing.expectEqualStrings("y.1.c", diag.path);
|
|
}
|
|
|
|
test "a scalar element with more path below it is PathNotViable" {
|
|
// Mutation check: delete the `.literal` arm of `Walk.element` and this
|
|
// update answers ok having replaced the `7` with `{b: 9}` -- a smaller
|
|
// version of the destruction the whole walk exists to have stopped.
|
|
var doc = try doc_with(testing.allocator, &.{
|
|
.{ .key = "y", .value = .{ .array = &.{
|
|
.{ .doc = &.{.{ .key = "b", .value = .{ .int32 = 1 } }} },
|
|
.{ .int32 = 7 },
|
|
} } },
|
|
});
|
|
defer doc.arena.deinit();
|
|
try testing.expectError(
|
|
error.PathNotViable,
|
|
apply_set(&doc, "y.$[].b", .{ .int32 = 9 }, .{}),
|
|
);
|
|
// And the first element, which the walk had already resolved, is untouched
|
|
// too: the refusal is taken during resolution, before any write.
|
|
try testing.expectEqual(@as(i32, 1), try y_b(&doc, 0));
|
|
try testing.expect(doc.get("y").?.array[1] == .int32);
|
|
}
|
|
|
|
test "$[<identifier>] writes only the elements its array filter selects" {
|
|
var doc = try array_doc(testing.allocator);
|
|
defer doc.arena.deinit();
|
|
var filters = [_]ArrayFilter{
|
|
.{ .pairs = &.{.{ .key = "i.b", .value = .{ .int32 = 3 } }} },
|
|
};
|
|
try apply_set(&doc, "y.$[i].b", .{ .int32 = 2 }, .{ .array_filters = &filters });
|
|
try testing.expectEqual(@as(i32, 2), try y_b(&doc, 0));
|
|
try testing.expectEqual(@as(i32, 1), try y_b(&doc, 1));
|
|
try testing.expect(filters[0].used);
|
|
|
|
// The identifier as the leaf replaces the whole element, and a filter on
|
|
// the identifier itself is how an array of scalars is addressed.
|
|
var scalars = try doc_with(testing.allocator, &.{
|
|
.{ .key = "y", .value = .{ .array = &.{ .{ .int32 = 1 }, .{ .int32 = 2 }, .{ .int32 = 3 }, .{ .int32 = 2 } } } },
|
|
});
|
|
defer scalars.arena.deinit();
|
|
var by_value = [_]ArrayFilter{
|
|
.{ .pairs = &.{.{ .key = "i", .value = .{ .int32 = 2 } }} },
|
|
};
|
|
try apply_set(&scalars, "y.$[i]", .{ .int32 = 9 }, .{ .array_filters = &by_value });
|
|
const y = scalars.get("y").?.array;
|
|
try testing.expectEqual(@as(i32, 1), y[0].int32);
|
|
try testing.expectEqual(@as(i32, 9), y[1].int32);
|
|
try testing.expectEqual(@as(i32, 3), y[2].int32);
|
|
try testing.expectEqual(@as(i32, 9), y[3].int32);
|
|
}
|
|
|
|
test "an array filter that selects nothing leaves the document alone" {
|
|
// Not an error: matched, modified nothing. The reply's `modifiedCount` is
|
|
// the only place this shows, which is why the corpus records outcomes.
|
|
var doc = try array_doc(testing.allocator);
|
|
defer doc.arena.deinit();
|
|
var filters = [_]ArrayFilter{
|
|
.{ .pairs = &.{.{ .key = "i.b", .value = .{ .int32 = 4 } }} },
|
|
};
|
|
try apply_set(&doc, "y.$[i].b", .{ .int32 = 2 }, .{ .array_filters = &filters });
|
|
try expect_y_untouched(&doc);
|
|
}
|
|
|
|
test "an identifier and its array filter each refuse the other's absence" {
|
|
var doc = try array_doc(testing.allocator);
|
|
defer doc.arena.deinit();
|
|
var diag: Diagnostic = .{};
|
|
|
|
// A path naming an identifier nothing binds.
|
|
var one = [_]ArrayFilter{.{ .pairs = &.{.{ .key = "i.b", .value = .{ .int32 = 3 } }} }};
|
|
try testing.expectError(
|
|
error.NoArrayFilter,
|
|
apply_set(&doc, "y.$[k].b", .{ .int32 = 2 }, .{ .array_filters = &one, .diag = &diag }),
|
|
);
|
|
try testing.expectEqualStrings("k", diag.segment);
|
|
try testing.expectEqualStrings("y.$[k].b", diag.path);
|
|
|
|
// A filter no path names. Mutation check: drop the `used` loop at the end
|
|
// of `validate` and this goes green -- and a misspelt identifier on the
|
|
// filter side becomes a silent no-op update.
|
|
var unused = [_]ArrayFilter{.{ .pairs = &.{.{ .key = "i.b", .value = .{ .int32 = 3 } }} }};
|
|
try testing.expectError(
|
|
error.UnusedArrayFilter,
|
|
apply_set(&doc, "y.b", .{ .int32 = 2 }, .{ .array_filters = &unused, .diag = &diag }),
|
|
);
|
|
try testing.expectEqualStrings("i", diag.segment);
|
|
try expect_y_untouched(&doc);
|
|
}
|
|
|
|
test "an array filter names exactly one identifier, spelled the one legal way" {
|
|
var doc = try array_doc(testing.allocator);
|
|
defer doc.arena.deinit();
|
|
var diag: Diagnostic = .{};
|
|
|
|
var empty = [_]ArrayFilter{.{ .pairs = &.{} }};
|
|
try testing.expectError(
|
|
error.EmptyArrayFilter,
|
|
apply_set(&doc, "y.$[i].b", .{ .int32 = 2 }, .{ .array_filters = &empty }),
|
|
);
|
|
|
|
var two = [_]ArrayFilter{.{ .pairs = &.{
|
|
.{ .key = "i.b", .value = .{ .int32 = 3 } },
|
|
.{ .key = "j.b", .value = .{ .int32 = 1 } },
|
|
} }};
|
|
try testing.expectError(
|
|
error.MultipleArrayFilterIdentifiers,
|
|
apply_set(&doc, "y.$[i].b", .{ .int32 = 2 }, .{ .array_filters = &two, .diag = &diag }),
|
|
);
|
|
try testing.expectEqualStrings("i", diag.segment);
|
|
try testing.expectEqualStrings("j", diag.other);
|
|
|
|
var dup = [_]ArrayFilter{
|
|
.{ .pairs = &.{.{ .key = "i.b", .value = .{ .int32 = 3 } }} },
|
|
.{ .pairs = &.{.{ .key = "i.b", .value = .{ .int32 = 1 } }} },
|
|
};
|
|
try testing.expectError(
|
|
error.DuplicateArrayFilter,
|
|
apply_set(&doc, "y.$[i].b", .{ .int32 = 2 }, .{ .array_filters = &dup }),
|
|
);
|
|
|
|
// Two fields naming the *same* identifier are one predicate, not two
|
|
// identifiers -- measured, and the reason the check is on the name rather
|
|
// than on the count of fields.
|
|
var same = [_]ArrayFilter{.{ .pairs = &.{
|
|
.{ .key = "i.b", .value = .{ .int32 = 3 } },
|
|
.{ .key = "i.c", .value = .{ .int32 = 1 } },
|
|
} }};
|
|
try apply_set(&doc, "y.$[i].b", .{ .int32 = 2 }, .{ .array_filters = &same });
|
|
try expect_y_untouched(&doc); // no element has both, so nothing moved
|
|
|
|
for ([_][]const u8{ "1x", "Ab", "a_b" }) |bad| {
|
|
var f = [_]ArrayFilter{.{ .pairs = &.{.{ .key = bad, .value = .{ .int32 = 3 } }} }};
|
|
try testing.expectError(
|
|
error.BadArrayFilterIdentifier,
|
|
apply_set(&doc, "y.$[i].b", .{ .int32 = 2 }, .{ .array_filters = &f, .diag = &diag }),
|
|
);
|
|
try testing.expectEqualStrings(bad, diag.segment);
|
|
}
|
|
}
|
|
|
|
test "$ writes the one element the query matched" {
|
|
// The operator that needs something the document alone does not hold.
|
|
var doc = try doc_with(testing.allocator, &.{
|
|
.{ .key = "y", .value = .{ .array = &.{
|
|
.{ .doc = &.{.{ .key = "b", .value = .{ .int32 = 1 } }} },
|
|
.{ .doc = &.{.{ .key = "b", .value = .{ .int32 = 1 } }} },
|
|
.{ .doc = &.{.{ .key = "b", .value = .{ .int32 = 2 } }} },
|
|
} } },
|
|
});
|
|
defer doc.arena.deinit();
|
|
// Two elements qualify and only the first is written. Mutation check: make
|
|
// `first_match` return the last index instead and this goes red both ways.
|
|
try apply_set(&doc, "y.$.b", .{ .int32 = 7 }, .{
|
|
.query = &.{.{ .key = "y.b", .value = .{ .int32 = 1 } }},
|
|
});
|
|
try testing.expectEqual(@as(i32, 7), try y_b(&doc, 0));
|
|
try testing.expectEqual(@as(i32, 1), try y_b(&doc, 1));
|
|
try testing.expectEqual(@as(i32, 2), try y_b(&doc, 2));
|
|
}
|
|
|
|
test "$ takes the element from a predicate on any field of it, including an operator" {
|
|
var doc = try array_doc(testing.allocator);
|
|
defer doc.arena.deinit();
|
|
// `{y.b: {$lt: 2}}` matches the second element, not the first.
|
|
try apply_set(&doc, "y.$.b", .{ .int32 = 7 }, .{
|
|
.query = &.{.{ .key = "y.b", .value = .{ .doc = &.{
|
|
.{ .key = "$lt", .value = .{ .int32 = 2 } },
|
|
} } }},
|
|
});
|
|
try testing.expectEqual(@as(i32, 3), try y_b(&doc, 0));
|
|
try testing.expectEqual(@as(i32, 7), try y_b(&doc, 1));
|
|
|
|
// `$elemMatch` names the element directly, and is the one predicate shape
|
|
// that is matched against the element rather than through a wrapper.
|
|
var em = try array_doc(testing.allocator);
|
|
defer em.arena.deinit();
|
|
try apply_set(&em, "y.$.b", .{ .int32 = 7 }, .{
|
|
.query = &.{.{ .key = "y", .value = .{ .doc = &.{
|
|
.{ .key = "$elemMatch", .value = .{ .doc = &.{.{ .key = "b", .value = .{ .int32 = 1 } }} } },
|
|
} } }},
|
|
});
|
|
try testing.expectEqual(@as(i32, 3), try y_b(&em, 0));
|
|
try testing.expectEqual(@as(i32, 7), try y_b(&em, 1));
|
|
}
|
|
|
|
test "$ with no predicate on the array is refused, and writes nothing" {
|
|
// The query matched the document by `_id`, so nothing recorded which
|
|
// element `$` meant. mongod refuses this, and the document is what makes
|
|
// that the only safe answer: any element would be a guess.
|
|
var doc = try array_doc(testing.allocator);
|
|
defer doc.arena.deinit();
|
|
try testing.expectError(error.NoPositionalMatch, apply_set(&doc, "y.$.b", .{ .int32 = 7 }, .{
|
|
.query = &.{.{ .key = "_id", .value = .{ .int32 = 1 } }},
|
|
}));
|
|
try expect_y_untouched(&doc);
|
|
|
|
// And with no query at all -- the upsert path, where the document being
|
|
// built never matched anything.
|
|
try testing.expectError(
|
|
error.NoPositionalMatch,
|
|
apply_set(&doc, "y.$.b", .{ .int32 = 7 }, .{}),
|
|
);
|
|
try expect_y_untouched(&doc);
|
|
}
|
|
|
|
test "a positional segment is refused in first position, and $ twice in one path" {
|
|
var doc = try array_doc(testing.allocator);
|
|
defer doc.arena.deinit();
|
|
var diag: Diagnostic = .{};
|
|
for ([_][]const u8{ "$", "$[]", "$[i]" }) |path| {
|
|
try testing.expectError(
|
|
error.PositionalFirst,
|
|
apply_set(&doc, path, .{ .int32 = 2 }, .{ .diag = &diag }),
|
|
);
|
|
try testing.expectEqualStrings(path, diag.segment);
|
|
}
|
|
// Two `$` cannot both be resolved: the query records one matched element
|
|
// per document, not one per level.
|
|
try testing.expectError(
|
|
error.TooManyPositional,
|
|
apply_set(&doc, "y.$.c.$.d", .{ .int32 = 0 }, .{ .diag = &diag }),
|
|
);
|
|
try testing.expectEqualStrings("y.$.c.$.d", diag.path);
|
|
try expect_y_untouched(&doc);
|
|
}
|
|
|
|
test "$rename refuses a positional path on either end" {
|
|
// A rename moves a field; a positional path names an element. mongod
|
|
// refuses both ends with its own message for each, and the destination is
|
|
// the *value* of the pair, which is the end easiest to forget.
|
|
var doc = try array_doc(testing.allocator);
|
|
defer doc.arena.deinit();
|
|
var diag: Diagnostic = .{};
|
|
try testing.expectError(error.RenameDynamicSource, apply(&doc, &doc_of(&.{
|
|
.{ .key = "$rename", .value = .{ .doc = &.{
|
|
.{ .key = "y.$[].b", .value = .{ .string = "z" } },
|
|
} } },
|
|
}), .{ .diag = &diag }));
|
|
try testing.expectEqualStrings("y.$[].b", diag.path);
|
|
|
|
try testing.expectError(error.RenameDynamicDestination, apply(&doc, &doc_of(&.{
|
|
.{ .key = "$rename", .value = .{ .doc = &.{
|
|
.{ .key = "y", .value = .{ .string = "z.$[i]" } },
|
|
} } },
|
|
}), .{ .diag = &diag }));
|
|
try testing.expectEqualStrings("z.$[i]", diag.path);
|
|
try expect_y_untouched(&doc);
|
|
}
|
|
|
|
test "nothing in the update is applied when one of its paths is refused" {
|
|
// The static half of the refusal is taken before the first write, so the
|
|
// good path in this update does not land either. Mutation check: move the
|
|
// `validate` call inside the operator loop and `ok` appears.
|
|
var doc = try array_doc(testing.allocator);
|
|
defer doc.arena.deinit();
|
|
try testing.expectError(error.NoArrayFilter, apply(&doc, &doc_of(&.{
|
|
.{ .key = "$set", .value = .{ .doc = &.{
|
|
.{ .key = "ok", .value = .{ .int32 = 1 } },
|
|
.{ .key = "y.$[i].b", .value = .{ .int32 = 2 } },
|
|
} } },
|
|
}), .{}));
|
|
try testing.expect(doc.get("ok") == null);
|
|
try expect_y_untouched(&doc);
|
|
}
|
|
|
|
test "a non-numeric segment under an array is PathNotViable, not a new field" {
|
|
// The rest of the class the positional forms belonged to. `y.nope.b` is
|
|
// not a positional operator and is refused for a different reason with a
|
|
// different code -- measured on mongod 8.3.7, which answers 28 here and 2
|
|
// for the positional forms.
|
|
const cases = [_][]const u8{ "y.nope.b", "y.nope", "y.$x.b" };
|
|
for (cases) |path| {
|
|
var doc = try array_doc(testing.allocator);
|
|
defer doc.arena.deinit();
|
|
var diag: Diagnostic = .{};
|
|
try testing.expectError(error.PathNotViable, apply(&doc, &doc_of(&.{
|
|
.{ .key = "$set", .value = .{ .doc = &.{.{ .key = path, .value = .{ .int32 = 2 } }} } },
|
|
}), .{ .diag = &diag }));
|
|
try expect_y_untouched(&doc);
|
|
}
|
|
}
|
|
|
|
test "a numeric segment still addresses an array element" {
|
|
// The regression guard for the refusal above: indexed paths are the one
|
|
// way into an array that does work, and they must keep working, including
|
|
// the null padding past the end.
|
|
var doc = try array_doc(testing.allocator);
|
|
defer doc.arena.deinit();
|
|
try apply(&doc, &doc_of(&.{
|
|
.{ .key = "$set", .value = .{ .doc = &.{
|
|
.{ .key = "y.0.b", .value = .{ .int32 = 7 } },
|
|
.{ .key = "y.3.b", .value = .{ .int32 = 8 } },
|
|
} } },
|
|
}), .{});
|
|
const y = doc.get("y").?;
|
|
try testing.expectEqual(@as(usize, 4), y.array.len);
|
|
try testing.expectEqual(@as(i32, 7), y.array[0].doc[0].value.int32);
|
|
try testing.expect(y.array[2] == .null);
|
|
try testing.expectEqual(@as(i32, 8), y.array[3].doc[0].value.int32);
|
|
}
|
|
|
|
test "a replacement is not a path, so it is not refused" {
|
|
// The one case out of seventeen where this server already agreed with
|
|
// mongod: `arrayFilters` alongside a replacement is ignored by both, and
|
|
// a replacement field named like a path is data, not a path.
|
|
var doc = try array_doc(testing.allocator);
|
|
defer doc.arena.deinit();
|
|
try apply(&doc, &doc_of(&.{
|
|
.{ .key = "z", .value = .{ .int32 = 1 } },
|
|
}), .{});
|
|
try testing.expect(doc.get("y") == null);
|
|
try testing.expectEqual(@as(i32, 1), doc.get("z").?.int32);
|
|
}
|