From 61151cc7dde49becb548e7b09b2fa3e475aefc82 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sun, 6 Sep 2026 14:19:54 +0200 Subject: [PATCH] feat: grow index --- src/index.zig | 198 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 198 insertions(+) diff --git a/src/index.zig b/src/index.zig index f91cb10..13bac6d 100644 --- a/src/index.zig +++ b/src/index.zig @@ -1737,3 +1737,201 @@ test "scanRange respects bounds and inclusivity" { try std.testing.expectEqual(@as(i64, 4), midExclusive[0].int); try std.testing.expectEqual(@as(i64, 5), midExclusive[1].int); + const openAbove = try scanRange(&db, allocator, "events", "score", .{ .value = .{ .int = 8 }, .inclusive = true }, null); + defer { + for (openAbove) |v| v.free(allocator); + allocator.free(openAbove); + } + try std.testing.expectEqual(@as(usize, 2), openAbove.len); +} + +test "CompositeIndexSet add remove save load round trip" { + const allocator = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const dbPath = try tmp.dir.realpathAlloc(allocator, "."); + defer allocator.free(dbPath); + + var db = try Db.open(allocator, dbPath, .{}); + defer db.close(); + + var set = try CompositeIndexSet.load(allocator, &db, "users"); + defer set.deinit(); + try std.testing.expectEqual(@as(usize, 0), set.specs.items.len); + + try set.add("city_age", &.{ "city", "age" }); + try set.add("city_age", &.{ "city", "age" }); // no-op, already present + try std.testing.expectEqual(@as(usize, 1), set.specs.items.len); + try set.save(&db, "users"); + + var reloaded = try CompositeIndexSet.load(allocator, &db, "users"); + defer reloaded.deinit(); + try std.testing.expect(reloaded.has("city_age")); + const spec = reloaded.find("city_age").?; + try std.testing.expectEqual(@as(usize, 2), spec.fields.len); + try std.testing.expectEqualStrings("city", spec.fields[0]); + try std.testing.expectEqualStrings("age", spec.fields[1]); + + try std.testing.expect(reloaded.remove("city_age")); + try std.testing.expect(!reloaded.remove("city_age")); + try reloaded.save(&db, "users"); + + var third = try CompositeIndexSet.load(allocator, &db, "users"); + defer third.deinit(); + try std.testing.expect(!third.has("city_age")); +} + +test "single-field and composite index metadata never shadow each other" { + // Both live under the "M" tag with table-name-suffixed keys that differ + // only in "INDEXES" vs "CINDEXES" - this pins down that they stay two + // genuinely distinct Db keys, not one overwriting the other. + const allocator = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const dbPath = try tmp.dir.realpathAlloc(allocator, "."); + defer allocator.free(dbPath); + + var db = try Db.open(allocator, dbPath, .{}); + defer db.close(); + + var single = try IndexSet.load(allocator, &db, "users"); + defer single.deinit(); + try single.add("email"); + try single.save(&db, "users"); + + var composite = try CompositeIndexSet.load(allocator, &db, "users"); + defer composite.deinit(); + try composite.add("city_age", &.{ "city", "age" }); + try composite.save(&db, "users"); + + var singleReloaded = try IndexSet.load(allocator, &db, "users"); + defer singleReloaded.deinit(); + try std.testing.expect(singleReloaded.has("email")); + + var compositeReloaded = try CompositeIndexSet.load(allocator, &db, "users"); + defer compositeReloaded.deinit(); + try std.testing.expect(compositeReloaded.has("city_age")); +} + +test "addCompositeEntry scanCompositeEq removeCompositeEntry round trip" { + const allocator = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const dbPath = try tmp.dir.realpathAlloc(allocator, "."); + defer allocator.free(dbPath); + + var db = try Db.open(allocator, dbPath, .{}); + defer db.close(); + + try addCompositeEntry(&db, allocator, "users", "city_age", &.{ .{ .text = "warsaw" }, .{ .int = 30 } }, .{ .int = 1 }); + try addCompositeEntry(&db, allocator, "users", "city_age", &.{ .{ .text = "warsaw" }, .{ .int = 40 } }, .{ .int = 2 }); + try addCompositeEntry(&db, allocator, "users", "city_age", &.{ .{ .text = "krakow" }, .{ .int = 30 } }, .{ .int = 3 }); + + // Full-tuple match: exactly one row. + const exact = try scanCompositeEq(&db, allocator, "users", "city_age", &.{ .{ .text = "warsaw" }, .{ .int = 30 } }); + defer { + for (exact) |v| v.free(allocator); + allocator.free(exact); + } + try std.testing.expectEqual(@as(usize, 1), exact.len); + try std.testing.expectEqual(@as(i64, 1), exact[0].int); + + // Leftmost-prefix match on just the first column: both warsaw rows, + // never the krakow one, even though it wasn't constrained on age at all. + const prefixMatch = try scanCompositeEq(&db, allocator, "users", "city_age", &.{.{ .text = "warsaw" }}); + defer { + for (prefixMatch) |v| v.free(allocator); + allocator.free(prefixMatch); + } + try std.testing.expectEqual(@as(usize, 2), prefixMatch.len); + + try removeCompositeEntry(&db, allocator, "users", "city_age", &.{ .{ .text = "warsaw" }, .{ .int = 30 } }, .{ .int = 1 }); + const afterRemove = try scanCompositeEq(&db, allocator, "users", "city_age", &.{.{ .text = "warsaw" }}); + defer { + for (afterRemove) |v| v.free(allocator); + allocator.free(afterRemove); + } + try std.testing.expectEqual(@as(usize, 1), afterRemove.len); + try std.testing.expectEqual(@as(i64, 2), afterRemove[0].int); +} + +test "scanCompositeRangeCursor narrows on the column after the matched prefix" { + const allocator = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const dbPath = try tmp.dir.realpathAlloc(allocator, "."); + defer allocator.free(dbPath); + + var db = try Db.open(allocator, dbPath, .{}); + defer db.close(); + + // Two cities, ages 20..29 in each - the composite index is (city, age). + var age: i64 = 20; + while (age < 30) : (age += 1) { + try addCompositeEntry(&db, allocator, "users", "city_age", &.{ .{ .text = "warsaw" }, .{ .int = age } }, .{ .int = age }); + try addCompositeEntry(&db, allocator, "users", "city_age", &.{ .{ .text = "krakow" }, .{ .int = age } }, .{ .int = age + 100 }); + } + + // city == "warsaw" and 23 <= age <= 26: never crosses into krakow's + // bucket even though its ages overlap byte-for-byte with warsaw's. + var midCursor = try scanCompositeRangeCursor(&db, allocator, "users", "city_age", &.{.{ .text = "warsaw" }}, .{ .value = .{ .int = 23 }, .inclusive = true }, .{ .value = .{ .int = 26 }, .inclusive = true }); + var midInclusive = std.ArrayList(Value).init(allocator); + defer midInclusive.deinit(); + while (try midCursor.next(allocator)) |pk| try midInclusive.append(pk); + midCursor.deinit(); + try std.testing.expectEqual(@as(usize, 4), midInclusive.items.len); + try std.testing.expectEqual(@as(i64, 23), midInclusive.items[0].int); + try std.testing.expectEqual(@as(i64, 26), midInclusive.items[midInclusive.items.len - 1].int); + + // Exclusive both sides: drops the two endpoints. + var exclusive = try scanCompositeRangeCursor(&db, allocator, "users", "city_age", &.{.{ .text = "warsaw" }}, .{ .value = .{ .int = 23 }, .inclusive = false }, .{ .value = .{ .int = 26 }, .inclusive = false }); + defer exclusive.deinit(); + const first = (try exclusive.next(allocator)).?; + defer first.free(allocator); + const second = (try exclusive.next(allocator)).?; + defer second.free(allocator); + try std.testing.expectEqual(@as(i64, 24), first.int); + try std.testing.expectEqual(@as(i64, 25), second.int); + try std.testing.expectEqual(@as(?Value, null), try exclusive.next(allocator)); + + // Open above: age >= 28 within warsaw only, never touching krakow. + var openAbove = try scanCompositeRangeCursor(&db, allocator, "users", "city_age", &.{.{ .text = "warsaw" }}, .{ .value = .{ .int = 28 }, .inclusive = true }, null); + defer openAbove.deinit(); + const a1 = (try openAbove.next(allocator)).?; + defer a1.free(allocator); + const a2 = (try openAbove.next(allocator)).?; + defer a2.free(allocator); + try std.testing.expectEqual(@as(i64, 28), a1.int); + try std.testing.expectEqual(@as(i64, 29), a2.int); + try std.testing.expectEqual(@as(?Value, null), try openAbove.next(allocator)); +} + +test "dropAllCompositeEntries clears an index without touching another one" { + const allocator = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const dbPath = try tmp.dir.realpathAlloc(allocator, "."); + defer allocator.free(dbPath); + + var db = try Db.open(allocator, dbPath, .{}); + defer db.close(); + + try addCompositeEntry(&db, allocator, "users", "city_age", &.{ .{ .text = "warsaw" }, .{ .int = 30 } }, .{ .int = 1 }); + try addCompositeEntry(&db, allocator, "users", "other_idx", &.{ .{ .int = 1 }, .{ .int = 2 } }, .{ .int = 1 }); + + try dropAllCompositeEntries(&db, allocator, "users", "city_age"); + + const cityAge = try scanCompositeEq(&db, allocator, "users", "city_age", &.{.{ .text = "warsaw" }}); + defer { + for (cityAge) |v| v.free(allocator); + allocator.free(cityAge); + } + try std.testing.expectEqual(@as(usize, 0), cityAge.len); + + const other = try scanCompositeEq(&db, allocator, "users", "other_idx", &.{.{ .int = 1 }}); + defer { + for (other) |v| v.free(allocator); + allocator.free(other); + } + try std.testing.expectEqual(@as(usize, 1), other.len); +}