update: $setOnInsert and $currentDate

`$setOnInsert` is `$set` on the branch that inserts and nothing at all on the
branch that updates, so `Options` grows the one bit that says which -- and
`build_upsert_doc` is the only caller that sets it. It does not go through
`op_set` because of the measured exception: writing `_id` is allowed on a
document being built and refused on one being rewritten. A document nobody
has yet has no identity to change.

`$currentDate` reads a clock, and the clock is a parameter. There is no
fallback to a global one: this file has no `io` to reach the real clock
through, and the handlers pass the same `std.Io.Timestamp` that `ttl_sweep`
and the cursor sweep already read. A caller that forgets gets the epoch --
deterministic and obviously wrong -- rather than something that changes
between runs.

Measured: `$currentDate: {d: false}` writes a date. The boolean says "a date",
not "whether", and either value means the same thing. `{$type: "timestamp"}`
writes a BSON timestamp, seconds in the high 32 bits and an ordinal in the
low ones; mongod fills the ordinal from the oplog, a standalone has none, so
it is 1.

current-date.json 3/11 -> 11/11, set-on-insert.json 0/9 -> 7/9. The two left
are not `$setOnInsert`'s: one is ConflictingUpdateOperators, and the other is
a pre-existing bug the corpus found -- an upsert never reports the `_id` it
generated, because `Engine.insert` writes it into the bytes and not back into
the caller's tree. `updateOne(..., {upsert: true}).upsertedId` is null here
and an ObjectId on mongod. Next commit.

244/244 unit tests.
This commit is contained in:
A.Shakhmatov
2026-08-10 21:21:32 +03:00
parent 482ddb3d1a
commit 1b6753fcec
2 changed files with 188 additions and 1 deletions

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);
}