╭ changed ────╮ │src/event.zig│ ╰─────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ 97 const issue_cursor = try event_id_to_issue.putCursor(hash.hashInt(hash_kind, &current_event_id));│ │ 98 const issue = try DB.HashMap(.read_write).init(issue_cursor); │ │ 99 │ │100 - try putIfDifferent(issue, hash.hashInt(hash_kind, "title"), data.title); │ │101 - try putIfDifferent(issue, hash.hashInt(hash_kind, "description"), data.description); │ │100 + try upsert(DB, hash_kind, issue, @TypeOf(data), data); │ │101 }, │ │102 } │ │103 } │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │111 │ │112 if (last_object_id_maybe) |*last_object_id| { │ │113 try haxy.put(hash.hashInt(hash_kind, "last-object-id"), .{ .bytes = last_object_id }); │ │115 - } │ │116 - } │ │117 - │ │118 - fn putIfDifferent(map: DB.HashMap(.read_write), key: hash.HashInt(hash_kind), value: []const u8) !void {│ │119 - if (try map.getCursor(key)) |value_cursor| { │ │120 - var buffer: [4096]u8 = undefined; │ │121 - const existing_value = try value_cursor.readBytes(&buffer); │ │122 - if (std.mem.eql(u8, existing_value, value)) { │ │123 - return; │ │124 - } │ │114 } │ │126 - │ │127 - try map.put(key, .{ .bytes = value }); │ │115 } │ │116 }; │ │117 │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │121 // create a new transaction in the database that runs the above-defined Ctx function │ │122 const history = try DB.ArrayList(.read_write).init(db.rootCursor()); │ │123 try history.appendContext(.{ .slot = try history.getSlot(-1) }, Ctx{ .repo_events = repo_events }); │ │124 +} │ │125 + │ │126 +fn upsert( │ │127 + comptime DB: type, │ │128 + comptime hash_kind: hash.HashKind, │ │129 + map: DB.HashMap(.read_write), │ │130 + comptime Data: type, │ │131 + data: Data, │ │132 +) !void { │ │133 + switch (@typeInfo(Data)) { │ │134 + .@"struct" => |struct_info| { │ │135 + inline for (struct_info.fields) |field| { │ │136 + try upsertField(DB, hash_kind, map, field.name, field.type, @field(data, field.name)); │ │137 + } │ │138 + }, │ │139 + else => @compileError("upsert expects a struct"), │ │140 + } │ │141 +} │ │142 + │ │143 +fn upsertField( │ │144 + comptime DB: type, │ │145 + comptime hash_kind: hash.HashKind, │ │146 + map: DB.HashMap(.read_write), │ │147 + comptime field_name: []const u8, │ │148 + comptime Field: type, │ │149 + value: Field, │ │150 +) !void { │ │151 + const key = hash.hashInt(hash_kind, field_name); │ │152 + │ │153 + switch (@typeInfo(Field)) { │ │154 + .pointer => |pointer_info| { │ │155 + if (pointer_info.size != .slice) { │ │156 + @compileError("upsert only supports slices, not other pointer types"); │ │157 + } │ │158 + │ │159 + if (pointer_info.child == u8) { │ │160 + try upsertBytes(DB, hash_kind, map, key, value); │ │161 + } else { │ │162 + try upsertArray(DB, hash_kind, map, key, Field, value); │ │163 + } │ │164 + }, │ │165 + .array => |array_info| { │ │166 + if (array_info.child == u8) { │ │167 + try upsertBytes(DB, hash_kind, map, key, &value); │ │168 + } else { │ │169 + try upsertArray(DB, hash_kind, map, key, Field, value); │ │170 + } │ │171 + }, │ │172 + .int => |int_info| switch (int_info.signedness) { │ │173 + .unsigned => try upsertUint(DB, hash_kind, map, key, value), │ │174 + .signed => try upsertInt(DB, hash_kind, map, key, value), │ │175 + }, │ │176 + else => @compileError("unsupported upsert field type: " ++ @typeName(Field)), │ │177 + } │ │178 +} │ │179 + │ │180 +fn upsertArray( │ │181 + comptime DB: type, │ │182 + comptime hash_kind: hash.HashKind, │ │183 + map: DB.HashMap(.read_write), │ │184 + key: hash.HashInt(hash_kind), │ │185 + comptime Value: type, │ │186 + value: Value, │ │187 +) !void { │ │188 + var existing_cursor_maybe = try map.getCursor(key); │ │189 + if (existing_cursor_maybe) |*existing_cursor| { │ │190 + if (try valueEqual(DB, hash_kind, Value, value, existing_cursor)) { │ │191 + return; │ │192 + } │ │193 + } │ │194 + │ │195 + var value_cursor = try map.putCursor(key); │ │196 + try value_cursor.write(.{ .slot = null }); │ │197 + try upsertValue(DB, hash_kind, Value, value, &value_cursor); │ │198 } │ │199 │ │200 +fn upsertValue( │ │201 + comptime DB: type, │ │202 + comptime hash_kind: hash.HashKind, │ │203 + comptime Value: type, │ │204 + value: Value, │ │205 + cursor: *DB.Cursor(.read_write), │ │206 +) !void { │ │207 + switch (@typeInfo(Value)) { │ │208 + .pointer => |pointer_info| { │ │209 + if (pointer_info.size == .slice and pointer_info.child == u8) { │ │210 + try cursor.write(.{ .bytes = value }); │ │211 + } else if (pointer_info.size == .slice) { │ │212 + const list = try DB.ArrayList(.read_write).init(cursor.*); │ │213 + for (value) |item| { │ │214 + var item_cursor = try list.appendCursor(); │ │215 + try upsertValue(DB, hash_kind, pointer_info.child, item, &item_cursor); │ │216 + } │ │217 + } else { │ │218 + @compileError("unsupported upsert value type: " ++ @typeName(Value)); │ │219 + } │ │220 + }, │ │221 + .array => |array_info| { │ │222 + const list = try DB.ArrayList(.read_write).init(cursor.*); │ │223 + for (value) |item| { │ │224 + var item_cursor = try list.appendCursor(); │ │225 + try upsertValue(DB, hash_kind, array_info.child, item, &item_cursor); │ │226 + } │ │227 + }, │ │228 + .int => |int_info| switch (int_info.signedness) { │ │229 + .unsigned => try cursor.write(.{ .uint = value }), │ │230 + .signed => try cursor.write(.{ .int = value }), │ │231 + }, │ │232 + else => @compileError("unsupported upsert value type: " ++ @typeName(Value)), │ │233 + } │ │234 +} │ │235 + │ │236 +fn upsertBytes( │ │237 + comptime DB: type, │ │238 + comptime hash_kind: hash.HashKind, │ │239 + map: DB.HashMap(.read_write), │ │240 + key: hash.HashInt(hash_kind), │ │241 + value: []const u8, │ │242 +) !void { │ │243 + var existing_cursor_maybe = try map.getCursor(key); │ │244 + if (existing_cursor_maybe) |*existing_cursor| { │ │245 + if (try bytesEqual(DB, existing_cursor, value)) { │ │246 + return; │ │247 + } │ │248 + } │ │249 + │ │250 + var value_cursor = try map.putCursor(key); │ │251 + var write_buffer: [1024]u8 = undefined; │ │252 + var writer = try value_cursor.writer(&write_buffer); │ │253 + try writer.interface.writeAll(value); │ │254 + try writer.finish(); │ │255 +} │ │256 + │ │257 +fn upsertUint( │ │258 + comptime DB: type, │ │259 + comptime hash_kind: hash.HashKind, │ │260 + map: DB.HashMap(.read_write), │ │261 + key: hash.HashInt(hash_kind), │ │262 + value: u64, │ │263 +) !void { │ │264 + if (try map.getCursor(key)) |value_cursor| { │ │265 + if (try value_cursor.readUint() == value) { │ │266 + return; │ │267 + } │ │268 + } │ │269 + │ │270 + try map.put(key, .{ .uint = value }); │ │271 +} │ │272 + │ │273 +fn upsertInt( │ │274 + comptime DB: type, │ │275 + comptime hash_kind: hash.HashKind, │ │276 + map: DB.HashMap(.read_write), │ │277 + key: hash.HashInt(hash_kind), │ │278 + value: i64, │ │279 +) !void { │ │280 + if (try map.getCursor(key)) |value_cursor| { │ │281 + if (try value_cursor.readInt() == value) { │ │282 + return; │ │283 + } │ │284 + } │ │285 + │ │286 + try map.put(key, .{ .int = value }); │ │287 +} │ │288 + │ │289 +fn valueEqual( │ │290 + comptime DB: type, │ │291 + comptime hash_kind: hash.HashKind, │ │292 + comptime Value: type, │ │293 + value: Value, │ │294 + cursor: *DB.Cursor(.read_only), │ │295 +) !bool { │ │296 + switch (@typeInfo(Value)) { │ │297 + .pointer => |pointer_info| { │ │298 + if (pointer_info.size == .slice and pointer_info.child == u8) { │ │299 + return try bytesEqual(DB, cursor, value); │ │300 + } else if (pointer_info.size == .slice) { │ │301 + return try arrayEqual(DB, hash_kind, pointer_info.child, value, cursor); │ │302 + } else { │ │303 + @compileError("unsupported upsert value type: " ++ @typeName(Value)); │ │304 + } │ │305 + }, │ │306 + .array => |array_info| return try arrayEqual(DB, hash_kind, array_info.child, value[0..], cursor),│ │307 + .int => |int_info| switch (int_info.signedness) { │ │308 + .unsigned => return (try cursor.readUint()) == value, │ │309 + .signed => return (try cursor.readInt()) == value, │ │310 + }, │ │311 + else => @compileError("unsupported upsert value type: " ++ @typeName(Value)), │ │312 + } │ │313 +} │ │314 + │ │315 +fn arrayEqual( │ │316 + comptime DB: type, │ │317 + comptime hash_kind: hash.HashKind, │ │318 + comptime Item: type, │ │319 + values: []const Item, │ │320 + cursor: *DB.Cursor(.read_only), │ │321 +) !bool { │ │322 + const list = try DB.ArrayList(.read_only).init(cursor.*); │ │323 + if (try list.count() != values.len) { │ │324 + return false; │ │325 + } │ │326 + │ │327 + for (values, 0..) |value, i| { │ │328 + var item_cursor = try list.getCursor(@intCast(i)) orelse return false; │ │329 + if (!try valueEqual(DB, hash_kind, Item, value, &item_cursor)) { │ │330 + return false; │ │331 + } │ │332 + } │ │333 + │ │334 + return true; │ │335 +} │ │336 + │ │337 +fn bytesEqual( │ │338 + comptime DB: type, │ │339 + cursor: *DB.Cursor(.read_only), │ │340 + value: []const u8, │ │341 +) !bool { │ │342 + var read_buffer: [1024]u8 = undefined; │ │343 + var reader = try cursor.reader(&read_buffer); │ │344 + if (reader.size != value.len) { │ │345 + return false; │ │346 + } │ │347 + │ │348 + var chunk_buffer: [1024]u8 = undefined; │ │349 + var offset: usize = 0; │ │350 + while (offset < value.len) { │ │351 + const chunk_len = @min(chunk_buffer.len, value.len - offset); │ │352 + try reader.interface.readSliceAll(chunk_buffer[0..chunk_len]); │ │353 + if (!std.mem.eql(u8, chunk_buffer[0..chunk_len], value[offset .. offset + chunk_len])) { │ │354 + return false; │ │355 + } │ │356 + offset += chunk_len; │ │357 + } │ │358 + │ │359 + return true; │ │360 +} │ │361 + │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ─────────╮ │src/test/event.zig│ ╰──────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │58 .issue = .{ │ │59 .title = "Login form clears password on validation error", │ │60 .description = "Submitting an invalid email address resets the password field and removes typed input. Preserve the field value and show an inline validation message.",│ │61 - .tags = &[_][]const u8{ "bug", "priority-high", "ui" }, │ │61 + .tags = &[_][]const u8{ "bug", "priority-low", "ui" }, │ │62 }, │ │63 }, │ │64 }, │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │191 const first_issue_description_value = try first_issue_description_cursor.readBytesAlloc(allocator, null); │ │192 defer allocator.free(first_issue_description_value); │ │193 try std.testing.expectEqualStrings(events_to_consume[1].data.issue.description, first_issue_description_value); │ │194 + │ │195 + // make sure the issue's tags were correctly edited │ │196 + const tags_cursor = try first_issue.getCursor(hash.hashInt(repo_opts.hash, "tags")) orelse return error.NotFound;│ │197 + const tags = try Repo.DB.ArrayList(.read_only).init(tags_cursor); │ │198 + try std.testing.expectEqual(events_to_consume[1].data.issue.tags.len, try tags.count()); │ │199 + │ │200 + for (events_to_consume[1].data.issue.tags, 0..) |expected_tag, i| { │ │201 + const tag_cursor = try tags.getCursor(@intCast(i)) orelse return error.NotFound; │ │202 + const tag_value = try tag_cursor.readBytesAlloc(allocator, null); │ │203 + defer allocator.free(tag_value); │ │204 + try std.testing.expectEqualStrings(expected_tag, tag_value); │ │205 + } │ │206 } │ │207 } │ │208 │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯