diff --git a/src/query.zig b/src/query.zig index fc98432..979ec5f 100644 --- a/src/query.zig +++ b/src/query.zig @@ -2381,3 +2381,159 @@ test "explain reports composite_eq and composite_range with matched/total field var db = try openTemp(&tmp, allocator); defer db.close(); + var table = try Table.open(&db, allocator, "users"); + defer table.deinit(); + try table.createCompositeIndex("city_age", &.{ "city", "age" }); + try table.put(.{ .int = 1 }, &.{ + .{ .name = "city", .value = .{ .text = "warsaw" } }, + .{ .name = "age", .value = .{ .int = 30 } }, + }); + + // Only the leading column pinned -> composite_eq over a strict prefix + // (matchedFields 1 of 2). `findCompositeMatch` only looks inside a + // top-level `and_` (see its doc comment), so a bare single `eq` outside + // one does not reach the composite index at all - wrapping in `and_` + // with itself as the sole conjunct is what actually exercises the + // leftmost-prefix match here. + const prefixPreds = [_]Predicate{ + .{ .eq = .{ .field = "city", .value = .{ .text = "warsaw" } } }, + }; + var prefixOnly = try table.explain(allocator, .{ .and_ = &prefixPreds }); + defer prefixOnly.deinit(allocator); + try std.testing.expectEqual(PlanKind.composite_eq, prefixOnly.kind); + try std.testing.expectEqualStrings("city_age", prefixOnly.indexName.?); + try std.testing.expectEqual(@as(usize, 1), prefixOnly.matchedFields); + try std.testing.expectEqual(@as(usize, 2), prefixOnly.totalFields); + + // Both columns pinned by equality -> composite_eq, fully matched. + const eqPreds = [_]Predicate{ + .{ .eq = .{ .field = "city", .value = .{ .text = "warsaw" } } }, + .{ .eq = .{ .field = "age", .value = .{ .int = 30 } } }, + }; + var fullEq = try table.explain(allocator, .{ .and_ = &eqPreds }); + defer fullEq.deinit(allocator); + try std.testing.expectEqual(PlanKind.composite_eq, fullEq.kind); + try std.testing.expectEqual(@as(usize, 2), fullEq.matchedFields); + + // Leading column pinned by equality, next column range-bound -> composite_range. + const rangePreds = [_]Predicate{ + .{ .eq = .{ .field = "city", .value = .{ .text = "warsaw" } } }, + .{ .gte = .{ .field = "age", .value = .{ .int = 18 } } }, + }; + var rangeMatch = try table.explain(allocator, .{ .and_ = &rangePreds }); + defer rangeMatch.deinit(allocator); + try std.testing.expectEqual(PlanKind.composite_range, rangeMatch.kind); + try std.testing.expectEqualStrings("city_age", rangeMatch.indexName.?); + try std.testing.expectEqual(@as(usize, 1), rangeMatch.matchedFields); +} + +test "explain never mutates the table (const receiver, no rows touched)" { + const allocator = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var db = try openTemp(&tmp, allocator); + defer db.close(); + + var table = try Table.open(&db, allocator, "users"); + defer table.deinit(); + try table.createIndex("city"); + try table.put(.{ .int = 1 }, &.{.{ .name = "city", .value = .{ .text = "warsaw" } }}); + + const pred = Predicate{ .eq = .{ .field = "city", .value = .{ .text = "warsaw" } } }; + const constTable: *const Table = &table; + var plan = try constTable.explain(allocator, pred); + defer plan.deinit(allocator); + try std.testing.expectEqual(PlanKind.index_eq, plan.kind); + + // The row explain() was asked about is still there afterwards. + const rows = try table.select(pred); + defer freeRows(allocator, rows); + try std.testing.expectEqual(@as(usize, 1), rows.len); +} + +test "updateWhere applies a set clause to every matching row and reports the count" { + const allocator = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var db = try openTemp(&tmp, allocator); + defer db.close(); + + var table = try Table.open(&db, allocator, "users"); + defer table.deinit(); + try table.put(.{ .int = 1 }, &.{ .{ .name = "city", .value = .{ .text = "warsaw" } }, .{ .name = "active", .value = .{ .int = 0 } } }); + try table.put(.{ .int = 2 }, &.{ .{ .name = "city", .value = .{ .text = "warsaw" } }, .{ .name = "active", .value = .{ .int = 0 } } }); + try table.put(.{ .int = 3 }, &.{ .{ .name = "city", .value = .{ .text = "krakow" } }, .{ .name = "active", .value = .{ .int = 0 } } }); + + const pred = Predicate{ .eq = .{ .field = "city", .value = .{ .text = "warsaw" } } }; + const updated = try table.updateWhere(pred, &.{.{ .name = "active", .value = .{ .int = 1 } }}); + try std.testing.expectEqual(@as(usize, 2), updated); + + var row1 = (try table.get(.{ .int = 1 })).?; + defer row1.deinit(); + try std.testing.expectEqual(Value{ .int = 1 }, row1.get("active").?); + try std.testing.expectEqualStrings("warsaw", row1.get("city").?.text); + + var row3 = (try table.get(.{ .int = 3 })).?; + defer row3.deinit(); + try std.testing.expectEqual(Value{ .int = 0 }, row3.get("active").?); +} + +test "updateWhere with a null predicate touches every row" { + const allocator = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var db = try openTemp(&tmp, allocator); + defer db.close(); + + var table = try Table.open(&db, allocator, "counters"); + defer table.deinit(); + try table.put(.{ .int = 1 }, &.{.{ .name = "n", .value = .{ .int = 5 } }}); + try table.put(.{ .int = 2 }, &.{.{ .name = "n", .value = .{ .int = 7 } }}); + + const updated = try table.updateWhere(null, &.{.{ .name = "flag", .value = .{ .text = "seen" } }}); + try std.testing.expectEqual(@as(usize, 2), updated); + + var row1 = (try table.get(.{ .int = 1 })).?; + defer row1.deinit(); + try std.testing.expectEqualStrings("seen", row1.get("flag").?.text); + // Untouched fields survive the merge. + try std.testing.expectEqual(Value{ .int = 5 }, row1.get("n").?); +} + +test "updateWhere adds a brand new field a matched row never had" { + const allocator = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var db = try openTemp(&tmp, allocator); + defer db.close(); + + var table = try Table.open(&db, allocator, "users"); + defer table.deinit(); + try table.put(.{ .int = 1 }, &.{.{ .name = "city", .value = .{ .text = "warsaw" } }}); + + _ = try table.updateWhere(null, &.{.{ .name = "tier", .value = .{ .text = "gold" } }}); + + var row = (try table.get(.{ .int = 1 })).?; + defer row.deinit(); + try std.testing.expectEqualStrings("gold", row.get("tier").?.text); + try std.testing.expectEqualStrings("warsaw", row.get("city").?.text); +} + +test "updateWhere keeps a secondary index in sync when the indexed field changes" { + const allocator = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var db = try openTemp(&tmp, allocator); + defer db.close(); + + var table = try Table.open(&db, allocator, "users"); + defer table.deinit(); + try table.createIndex("city"); + try table.put(.{ .int = 1 }, &.{.{ .name = "city", .value = .{ .text = "warsaw" } }}); + + const updated = try table.updateWhere( + .{ .eq = .{ .field = "city", .value = .{ .text = "warsaw" } } }, + &.{.{ .name = "city", .value = .{ .text = "krakow" } }}, + ); + try std.testing.expectEqual(@as(usize, 1), updated); +