M3: the update operators, and 's modifiers #8

Merged
dev merged 9 commits from m3-operators into main 2026-08-10 18:41:26 +00:00
2 changed files with 188 additions and 1 deletions
Showing only changes of commit 1b6753fcec - Show all commits

View File

@@ -1977,6 +1977,7 @@ fn cmd_update(ctx: *Context, msg: *wire.Message, reply: *wire.Reply) !void {
"update.updates.arrayFilters",
) orelse return,
.query = q,
.now_ms = std.Io.Timestamp.now(ctx.io, .real).toMilliseconds(),
.diag = &diag,
};
// Before the scan, not after: an update naming an identifier nothing
@@ -2102,6 +2103,7 @@ fn cmd_find_and_modify(ctx: *Context, msg: *wire.Message, reply: *wire.Reply) !v
"findAndModify.arrayFilters",
) orelse return,
.query = q,
.now_ms = std.Io.Timestamp.now(ctx.io, .real).toMilliseconds(),
.diag = &diag,
};
if (doc_arg(msg.body.get("update"))) |u| {
@@ -4313,6 +4315,17 @@ fn update_refusal(reply: *wire.Reply, err: anyerror, diag: update.Diagnostic) !v
"Unrecognized or invalid $push modifier '{s}' at field '{s}'",
.{ diag.segment, diag.path },
)),
error.BadCurrentDateType => return bad_value(
reply,
"The '$type' string field is required to be 'date' or 'timestamp': " ++
"{$currentDate: {field : {$type: 'date'}}}",
),
error.BadCurrentDateOperand => return bad_value(reply, try std.fmt.allocPrint(
arena,
"{s} is not valid type for $currentDate. Please use a boolean ('true') or a " ++
"$type expression ({{$type: 'timestamp/date'}}).",
.{diag.other},
)),
error.PathNotViable => return reply.put_error(
@intFromEnum(ErrorCode.path_not_viable),
"PathNotViable",
@@ -4410,7 +4423,11 @@ fn build_upsert_doc(
const owned = try arena.create(bson.Document);
owned.* = bson.Document{ .arena = std.heap.ArenaAllocator.init(arena), .pairs = try pairs.toOwnedSlice(arena) };
// Apply update operators to build the final doc; _id handled by insert.
try update.apply(owned, &.{ .arena = undefined, .pairs = u_doc }, opts);
// `inserting` is what `$setOnInsert` asks about, and this is the only
// caller that answers yes.
var insert_opts = opts;
insert_opts.inserting = true;
try update.apply(owned, &.{ .arena = undefined, .pairs = u_doc }, insert_opts);
return owned;
}

View File

@@ -34,6 +34,10 @@ pub const UpdateError = error{
PullAllNeedsArray,
/// `$each` that is not an array.
BadEach,
/// `$currentDate` handed a document that is not `{$type: date|timestamp}`.
BadCurrentDateType,
/// `$currentDate` handed something that is neither a bool nor a document.
BadCurrentDateOperand,
/// A `$push` modifier that is unknown, or whose operand is not what it
/// takes: `$slice`/`$position` want a number, `$sort` a number or a
/// one-field document.
@@ -115,6 +119,15 @@ pub const Options = struct {
/// 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,
/// Whether this application is building a document an upsert will insert.
/// `$setOnInsert` is the one operator that asks.
inserting: bool = false,
/// The clock `$currentDate` reads, in Unix milliseconds. There is no
/// fallback to a global one: this file has no `io` to read the real clock
/// through, and a caller that forgets gets the epoch rather than a value
/// that changes between runs. The command handlers pass the same clock
/// `ttl_sweep` and the cursor sweep already use.
now_ms: i64 = 0,
diag: ?*Diagnostic = null,
};
@@ -636,6 +649,15 @@ fn apply_operator(
// shape is checked once here rather than at the top of each.
const ops = doc_pairs(value) orelse return error.InvalidUpdate;
if (std.mem.eql(u8, op, "$set")) return op_set(arena, pairs, ops, opts);
// `$setOnInsert` is `$set` on the branch that inserts and nothing at all
// on the branch that updates -- including its `_id` rule, which is why it
// does not go through `op_set`: writing `_id` is allowed on a document
// being built and refused on one being rewritten. Measured.
if (std.mem.eql(u8, op, "$setOnInsert")) {
if (!opts.inserting) return;
return op_set_on_insert(arena, pairs, ops, opts);
}
if (std.mem.eql(u8, op, "$currentDate")) return op_current_date(arena, pairs, ops, opts);
if (std.mem.eql(u8, op, "$unset")) return op_unset(arena, pairs, ops, opts);
if (std.mem.eql(u8, op, "$inc")) return op_arith(arena, pairs, ops, opts, .add);
if (std.mem.eql(u8, op, "$mul")) return op_arith(arena, pairs, ops, opts, .mul);
@@ -664,6 +686,72 @@ fn op_set(
}
}
fn op_set_on_insert(
arena: std.mem.Allocator,
pairs: *std.ArrayListUnmanaged(bson.Pair),
ops: []const bson.Pair,
opts: Options,
) UpdateError!void {
for (ops) |p| {
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, opts.diag);
}
}
}
/// `$currentDate`: write the clock, as a date or as a BSON timestamp.
///
/// The operand says which. A bool -- **either** bool, measured: `false` writes
/// a date too, the value is ignored -- means a date; `{$type: "date"}` and
/// `{$type: "timestamp"}` say so; anything else is refused.
fn op_current_date(
arena: std.mem.Allocator,
pairs: *std.ArrayListUnmanaged(bson.Pair),
ops: []const bson.Pair,
opts: Options,
) UpdateError!void {
const now = opts.now_ms;
for (ops) |p| {
const value: bson.Value = switch (try current_date_kind(p.value, opts, p.key)) {
.date => .{ .datetime = now },
// A BSON timestamp is seconds in the high 32 bits and an ordinal
// in the low ones. mongod fills the ordinal from the oplog; a
// standalone has no oplog, so it is 1 -- the same answer twice in
// one second is the same timestamp, which nothing here reads.
.timestamp => .{ .timestamp = (@as(u64, @intCast(@divFloor(now, 1000))) << 32) | 1 },
};
for (try resolve(arena, pairs.items, p.key, opts)) |segs| {
try set_path(arena, pairs, segs, value, p.key, opts.diag);
}
}
}
fn current_date_kind(
v: bson.Value,
opts: Options,
path: []const u8,
) UpdateError!enum { date, timestamp } {
switch (v) {
.bool => return .date,
.doc => |spec| {
const t = bson.get_pair(spec, "$type") orelse {
note(opts.diag, path, "");
return error.BadCurrentDateType;
};
if (t == .string) {
if (std.mem.eql(u8, t.string, "date")) return .date;
if (std.mem.eql(u8, t.string, "timestamp")) return .timestamp;
}
note(opts.diag, path, "");
return error.BadCurrentDateType;
},
else => {
if (opts.diag) |d| d.* = .{ .path = path, .other = v.type_name() };
return error.BadCurrentDateOperand;
},
}
}
fn op_unset(
arena: std.mem.Allocator,
pairs: *std.ArrayListUnmanaged(bson.Pair),
@@ -2424,3 +2512,85 @@ test "a $push modifier that is not one, or is handed the wrong thing, is refused
}
try testing.expectEqual(@as(usize, 1), doc.get("t").?.array.len);
}
test "$setOnInsert writes only on the branch that inserts" {
// Mutation check: drop the `opts.inserting` guard and the first half goes
// red -- an update would gain a field that is only supposed to exist on a
// document nobody had before.
var updating = try doc_with(testing.allocator, &.{
.{ .key = "_id", .value = .{ .int32 = 1 } },
.{ .key = "a", .value = .{ .int32 = 100 } },
});
defer updating.arena.deinit();
try apply(&updating, &doc_of(&.{
.{ .key = "$setOnInsert", .value = .{ .doc = &.{.{ .key = "a", .value = .{ .int32 = 1 } }} } },
.{ .key = "$set", .value = .{ .doc = &.{.{ .key = "b", .value = .{ .int32 = 2 } }} } },
}), .{});
try testing.expectEqual(@as(i32, 100), updating.get("a").?.int32);
try testing.expectEqual(@as(i32, 2), updating.get("b").?.int32);
var inserting = try doc_with(testing.allocator, &.{.{ .key = "k", .value = .{ .int32 = 1 } }});
defer inserting.arena.deinit();
try apply(&inserting, &doc_of(&.{
.{ .key = "$setOnInsert", .value = .{ .doc = &.{.{ .key = "a", .value = .{ .int32 = 1 } }} } },
}), .{ .inserting = true });
try testing.expectEqual(@as(i32, 1), inserting.get("a").?.int32);
}
test "$setOnInsert may write _id, where $set may not" {
// The one place the immutability rule does not apply: a document being
// built has no identity yet to change.
var doc = try doc_with(testing.allocator, &.{.{ .key = "k", .value = .{ .int32 = 1 } }});
defer doc.arena.deinit();
try apply(&doc, &doc_of(&.{
.{ .key = "$setOnInsert", .value = .{ .doc = &.{.{ .key = "_id", .value = .{ .int32 = 9 } }} } },
}), .{ .inserting = true });
try testing.expectEqual(@as(i32, 9), doc.get("_id").?.int32);
try testing.expectError(error.ImmutableId, apply(&doc, &doc_of(&.{
.{ .key = "$set", .value = .{ .doc = &.{.{ .key = "_id", .value = .{ .int32 = 8 } }} } },
}), .{ .inserting = true }));
}
test "$currentDate writes the clock it was handed" {
// The clock is a parameter, so the result is a value rather than a moving
// target -- and the server passes the same one `ttl_sweep` reads.
var doc = try doc_with(testing.allocator, &.{.{ .key = "a", .value = .{ .int32 = 1 } }});
defer doc.arena.deinit();
try apply(&doc, &doc_of(&.{
.{ .key = "$currentDate", .value = .{ .doc = &.{
.{ .key = "d", .value = .{ .bool = true } },
// Measured: `false` writes a date too. The boolean says "a date",
// not "whether".
.{ .key = "f", .value = .{ .bool = false } },
.{ .key = "e", .value = .{ .doc = &.{.{ .key = "$type", .value = .{ .string = "date" } }} } },
.{ .key = "t", .value = .{ .doc = &.{.{ .key = "$type", .value = .{ .string = "timestamp" } }} } },
} } },
}), .{ .now_ms = 1_700_000_000_123 });
try testing.expectEqual(@as(i64, 1_700_000_000_123), doc.get("d").?.datetime);
try testing.expectEqual(@as(i64, 1_700_000_000_123), doc.get("f").?.datetime);
try testing.expectEqual(@as(i64, 1_700_000_000_123), doc.get("e").?.datetime);
// Seconds in the high 32 bits, an ordinal in the low ones.
try testing.expectEqual(@as(u64, (1_700_000_000 << 32) | 1), doc.get("t").?.timestamp);
}
test "$currentDate refuses an operand that names no type" {
var doc = try doc_with(testing.allocator, &.{.{ .key = "a", .value = .{ .int32 = 1 } }});
defer doc.arena.deinit();
var diag: Diagnostic = .{};
try testing.expectError(error.BadCurrentDateType, apply(&doc, &doc_of(&.{
.{ .key = "$currentDate", .value = .{ .doc = &.{
.{ .key = "d", .value = .{ .doc = &.{.{ .key = "$type", .value = .{ .string = "nope" } }} } },
} } },
}), .{ .diag = &diag }));
try testing.expectError(error.BadCurrentDateType, apply(&doc, &doc_of(&.{
.{ .key = "$currentDate", .value = .{ .doc = &.{
.{ .key = "d", .value = .{ .doc = &.{.{ .key = "a", .value = .{ .int32 = 1 } }} } },
} } },
}), .{ .diag = &diag }));
try testing.expectError(error.BadCurrentDateOperand, apply(&doc, &doc_of(&.{
.{ .key = "$currentDate", .value = .{ .doc = &.{.{ .key = "d", .value = .{ .int32 = 1 } }} } },
}), .{ .diag = &diag }));
try testing.expectEqualStrings("int", diag.other);
try testing.expect(doc.get("d") == null);
}