diff --git a/src/commands.zig b/src/commands.zig index 7907b13..dfa6e6d 100644 --- a/src/commands.zig +++ b/src/commands.zig @@ -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; } diff --git a/src/update.zig b/src/update.zig index 6fa5d02..a6d804f 100644 --- a/src/update.zig +++ b/src/update.zig @@ -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); +}