feat: grow index

This commit is contained in:
dualfroz committed 2026-09-06 14:19:54 +02:00
1 parent 1ca072480d
commit 61151cc7dd
1 file changed
+198
+198
View File
@@ -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, 4), midExclusive[0].int);
try std.testing.expectEqual(@as(i64, 5), midExclusive[1].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);
}