Files
MultiforaDB/src/update.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

427 lines
17 KiB
Zig

//! Update operators: $set, $unset, $inc, $push, $pull, $rename with
//! dot-path navigation. Mutates the document's pairs in place, allocating
//! from the document's own arena.
const std = @import("std");
const bson = @import("bson.zig");
const query = @import("query.zig");
pub const UpdateError = error{ ImmutableId, InvalidUpdate, OutOfMemory };
const max_path_segments = 16;
/// Apply an update document (whose fields are operator documents) to `doc`.
pub fn apply(doc: *bson.Document, update: *const bson.Document) UpdateError!void {
const arena = doc.arena.allocator();
var pairs = try copy_to_list(bson.Pair, arena, doc.pairs);
for (update.pairs) |op| {
if (op.key.len == 0 or op.key[0] != '$') return error.InvalidUpdate;
try apply_operator(arena, &pairs, op.key, op.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,
) UpdateError!void {
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;
var segs: [max_path_segments][]const u8 = undefined;
const n = split_path(p.key, &segs) orelse return error.InvalidUpdate;
try set_path(arena, pairs, segs[0..n], try bson.copy_value(arena, p.value));
}
return;
}
if (std.mem.eql(u8, op, "$unset")) {
const ops = doc_pairs(value) orelse return error.InvalidUpdate;
for (ops) |p| {
var segs: [max_path_segments][]const u8 = undefined;
const n = split_path(p.key, &segs) orelse continue;
unset_path(arena, pairs, segs[0..n]);
}
return;
}
if (std.mem.eql(u8, op, "$inc")) {
const ops = doc_pairs(value) orelse return error.InvalidUpdate;
for (ops) |p| {
var segs: [max_path_segments][]const u8 = undefined;
const n = split_path(p.key, &segs) orelse return error.InvalidUpdate;
const current = get_value(pairs.items, segs[0..n]) 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[0..n], sum);
}
return;
}
if (std.mem.eql(u8, op, "$push")) {
const ops = doc_pairs(value) orelse return error.InvalidUpdate;
for (ops) |p| {
var segs: [max_path_segments][]const u8 = undefined;
const n = split_path(p.key, &segs) orelse return error.InvalidUpdate;
const current_opt = get_value(pairs.items, segs[0..n]);
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[0..n], .{ .array = try items.toOwnedSlice(arena) });
continue;
}
}
try items.append(arena, try bson.copy_value(arena, p.value));
try set_path(arena, pairs, segs[0..n], .{ .array = try items.toOwnedSlice(arena) });
}
return;
}
if (std.mem.eql(u8, op, "$pull")) {
const ops = doc_pairs(value) orelse return error.InvalidUpdate;
for (ops) |p| {
var segs: [max_path_segments][]const u8 = undefined;
const n = split_path(p.key, &segs) orelse return error.InvalidUpdate;
const current = get_value(pairs.items, segs[0..n]) 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[0..n], .{ .array = try items.toOwnedSlice(arena) });
}
return;
}
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);
}
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,
) 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);
};
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);
pairs.items[idx].value = .{ .doc = try sub_pairs.toOwnedSlice(arena) };
},
.array => |arr| {
const index = parse_index(segs[1]) orelse {
// treat as non-array: replace with a doc
var sub_pairs: std.ArrayListUnmanaged(bson.Pair) = .empty;
defer sub_pairs.deinit(arena);
try set_path(arena, &sub_pairs, segs[1..], value);
pairs.items[idx].value = .{ .doc = try sub_pairs.toOwnedSlice(arena) };
return;
};
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);
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);
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);
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 };
},
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 "non-operator update rejected" {
var doc = bson.Document{ .arena = std.heap.ArenaAllocator.init(testing.allocator), .pairs = &.{} };
defer doc.arena.deinit();
try testing.expectError(error.InvalidUpdate, apply(&doc, &doc_of(&.{
.{ .key = "plain", .value = .{ .int32 = 1 } },
})));
}