From b16f2ddf47c281b04ea25decc6c84f999f03226f Mon Sep 17 00:00:00 2001 From: DualFroz Date: Wed, 9 Sep 2026 14:38:34 +0200 Subject: [PATCH] feat: extend query --- src/query.zig | 164 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 164 insertions(+) diff --git a/src/query.zig b/src/query.zig index 979ec5f..646d8b6 100644 --- a/src/query.zig +++ b/src/query.zig @@ -2537,3 +2537,167 @@ test "updateWhere keeps a secondary index in sync when the indexed field changes ); try std.testing.expectEqual(@as(usize, 1), updated); + // The stale index entry is gone, a fresh one under the new value works, + // and explain() confirms the index (not a full scan) still serves it. + const stale = try table.select(.{ .eq = .{ .field = "city", .value = .{ .text = "warsaw" } } }); + defer freeRows(allocator, stale); + try std.testing.expectEqual(@as(usize, 0), stale.len); + + const fresh = try table.select(.{ .eq = .{ .field = "city", .value = .{ .text = "krakow" } } }); + defer freeRows(allocator, fresh); + try std.testing.expectEqual(@as(usize, 1), fresh.len); + + const constTable: *const Table = &table; + var plan = try constTable.explain(allocator, .{ .eq = .{ .field = "city", .value = .{ .text = "krakow" } } }); + defer plan.deinit(allocator); + try std.testing.expectEqual(PlanKind.index_eq, plan.kind); +} + +test "updateWhere keeps a composite index in sync across leading and trailing column moves" { + 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.createCompositeIndex("city_age", &.{ "city", "age" }); + + try table.put(.{ .int = 1 }, &.{ + .{ .name = "city", .value = .{ .text = "warsaw" } }, + .{ .name = "age", .value = .{ .int = 30 } }, + .{ .name = "name", .value = .{ .text = "alice" } }, + }); + try table.put(.{ .int = 2 }, &.{ + .{ .name = "city", .value = .{ .text = "warsaw" } }, + .{ .name = "age", .value = .{ .int = 31 } }, + .{ .name = "name", .value = .{ .text = "bob" } }, + }); + try table.put(.{ .int = 3 }, &.{ + .{ .name = "city", .value = .{ .text = "krakow" } }, + .{ .name = "age", .value = .{ .int = 30 } }, + .{ .name = "name", .value = .{ .text = "carol" } }, + }); + try table.put(.{ .int = 4 }, &.{ + .{ .name = "city", .value = .{ .text = "krakow" } }, + .{ .name = "age", .value = .{ .int = 40 } }, + .{ .name = "name", .value = .{ .text = "dave" } }, + }); + + // updateWhere matched through the composite index (both columns pinned + // by equality) moves the TRAILING column (age). The old composite entry + // for row 1 must vanish and a fresh one under the new age must serve it. + { + const preds = [_]Predicate{ + .{ .eq = .{ .field = "city", .value = .{ .text = "warsaw" } } }, + .{ .eq = .{ .field = "age", .value = .{ .int = 30 } } }, + }; + const updated = try table.updateWhere( + .{ .and_ = &preds }, + &.{.{ .name = "age", .value = .{ .int = 45 } }}, + ); + try std.testing.expectEqual(@as(usize, 1), updated); + } + { + const stalePreds = [_]Predicate{ + .{ .eq = .{ .field = "city", .value = .{ .text = "warsaw" } } }, + .{ .eq = .{ .field = "age", .value = .{ .int = 30 } } }, + }; + const stale = try table.select(.{ .and_ = &stalePreds }); + defer freeRows(allocator, stale); + try std.testing.expectEqual(@as(usize, 0), stale.len); + + const freshPreds = [_]Predicate{ + .{ .eq = .{ .field = "city", .value = .{ .text = "warsaw" } } }, + .{ .eq = .{ .field = "age", .value = .{ .int = 45 } } }, + }; + const fresh = try table.select(.{ .and_ = &freshPreds }); + defer freeRows(allocator, fresh); + try std.testing.expectEqual(@as(usize, 1), fresh.len); + try std.testing.expectEqual(@as(i64, 1), fresh[0].get(pkField).?.int); + + const constTable: *const Table = &table; + var plan = try constTable.explain(allocator, .{ .and_ = &freshPreds }); + defer plan.deinit(allocator); + try std.testing.expectEqual(PlanKind.composite_eq, plan.kind); + try std.testing.expectEqualStrings("city_age", plan.indexName.?); + try std.testing.expectEqual(@as(usize, 2), plan.matchedFields); + } + + // Same check but moving the LEADING column (city) instead of the + // trailing one - a different code path in the composite key encoding + // (the prefix itself shifts, not just the suffix). + { + const preds = [_]Predicate{ + .{ .eq = .{ .field = "city", .value = .{ .text = "warsaw" } } }, + .{ .eq = .{ .field = "age", .value = .{ .int = 31 } } }, + }; + const updated = try table.updateWhere( + .{ .and_ = &preds }, + &.{.{ .name = "city", .value = .{ .text = "gdansk" } }}, + ); + try std.testing.expectEqual(@as(usize, 1), updated); + } + { + const stalePreds = [_]Predicate{ + .{ .eq = .{ .field = "city", .value = .{ .text = "warsaw" } } }, + .{ .eq = .{ .field = "age", .value = .{ .int = 31 } } }, + }; + const stale = try table.select(.{ .and_ = &stalePreds }); + defer freeRows(allocator, stale); + try std.testing.expectEqual(@as(usize, 0), stale.len); + + const freshPreds = [_]Predicate{ + .{ .eq = .{ .field = "city", .value = .{ .text = "gdansk" } } }, + .{ .eq = .{ .field = "age", .value = .{ .int = 31 } } }, + }; + const fresh = try table.select(.{ .and_ = &freshPreds }); + defer freeRows(allocator, fresh); + try std.testing.expectEqual(@as(usize, 1), fresh.len); + try std.testing.expectEqual(@as(i64, 2), fresh[0].get(pkField).?.int); + } + + // updateWhere reached through composite_range (leading column pinned by + // equality, trailing column range-bound) must update every row the + // range covers, and leave the composite index able to keep serving + // both afterwards even though only the unindexed "name" field changed. + { + const rangePreds = [_]Predicate{ + .{ .eq = .{ .field = "city", .value = .{ .text = "krakow" } } }, + .{ .gte = .{ .field = "age", .value = .{ .int = 30 } } }, + }; + const constTable: *const Table = &table; + var rangePlan = try constTable.explain(allocator, .{ .and_ = &rangePreds }); + defer rangePlan.deinit(allocator); + try std.testing.expectEqual(PlanKind.composite_range, rangePlan.kind); + try std.testing.expectEqualStrings("city_age", rangePlan.indexName.?); + + const updated = try table.updateWhere( + .{ .and_ = &rangePreds }, + &.{.{ .name = "name", .value = .{ .text = "updated" } }}, + ); + try std.testing.expectEqual(@as(usize, 2), updated); + } + + // Final sanity sweep: every row is still reachable through the + // composite index under exactly its current (city, age), nothing was + // lost, duplicated, or left under a stale key. + const expectations = [_]struct { pk: i64, city: []const u8, age: i64, name: []const u8 }{ + .{ .pk = 1, .city = "warsaw", .age = 45, .name = "alice" }, + .{ .pk = 2, .city = "gdansk", .age = 31, .name = "bob" }, + .{ .pk = 3, .city = "krakow", .age = 30, .name = "updated" }, + .{ .pk = 4, .city = "krakow", .age = 40, .name = "updated" }, + }; + for (expectations) |exp| { + const preds = [_]Predicate{ + .{ .eq = .{ .field = "city", .value = .{ .text = exp.city } } }, + .{ .eq = .{ .field = "age", .value = .{ .int = exp.age } } }, + }; + const rows = try table.select(.{ .and_ = &preds }); + defer freeRows(allocator, rows); + try std.testing.expectEqual(@as(usize, 1), rows.len); + try std.testing.expectEqual(exp.pk, rows[0].get(pkField).?.int); + try std.testing.expectEqualStrings(exp.name, rows[0].get("name").?.text); + } +