From 8ad8dd3e8c435e08e7e61d543834a288ce221f39 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Wed, 2 Sep 2026 16:08:08 +0200 Subject: [PATCH] feat: expand collections --- src/collections.zig | 159 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 159 insertions(+) diff --git a/src/collections.zig b/src/collections.zig index f3aa611..c3cfc52 100644 --- a/src/collections.zig +++ b/src/collections.zig @@ -1262,3 +1262,162 @@ test "list range supports negative indices redis style" { try std.testing.expectEqual(@as(usize, 5), clamped.len); } +test "zset add score incrBy remove rank" { + const allocator = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var db = try openTemp(&tmp, allocator); + defer db.close(); + + try ZSet.add(&db, allocator, "leaderboard", "alice", 10); + try ZSet.add(&db, allocator, "leaderboard", "bob", 20); + try ZSet.add(&db, allocator, "leaderboard", "carol", 5); + + try std.testing.expectEqual(@as(f64, 10), (try ZSet.score(&db, allocator, "leaderboard", "alice")).?); + try std.testing.expectEqual(@as(usize, 3), try ZSet.card(&db, allocator, "leaderboard")); + + const newScore = try ZSet.incrBy(&db, allocator, "leaderboard", "alice", 100); + try std.testing.expectEqual(@as(f64, 110), newScore); + + // Ranks ascending by score: carol(5), bob(20), alice(110). + try std.testing.expectEqual(@as(usize, 0), (try ZSet.rank(&db, allocator, "leaderboard", "carol")).?); + try std.testing.expectEqual(@as(usize, 2), (try ZSet.rank(&db, allocator, "leaderboard", "alice")).?); + try std.testing.expect(try ZSet.rank(&db, allocator, "leaderboard", "missing") == null); + + try std.testing.expect(try ZSet.remove(&db, allocator, "leaderboard", "bob")); + try std.testing.expect(!try ZSet.remove(&db, allocator, "leaderboard", "bob")); + try std.testing.expectEqual(@as(usize, 2), try ZSet.card(&db, allocator, "leaderboard")); +} + +test "zset rangeByRank and rangeByScore return ascending order" { + const allocator = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var db = try openTemp(&tmp, allocator); + defer db.close(); + + try ZSet.add(&db, allocator, "z", "a", -5.5); + try ZSet.add(&db, allocator, "z", "b", 0); + try ZSet.add(&db, allocator, "z", "c", 3.25); + try ZSet.add(&db, allocator, "z", "d", 100); + + const byRank = try ZSet.rangeByRank(&db, allocator, "z", 0, -1); + defer { + for (byRank) |m| m.free(allocator); + allocator.free(byRank); + } + try std.testing.expectEqual(@as(usize, 4), byRank.len); + try std.testing.expectEqualStrings("a", byRank[0].member); + try std.testing.expectEqualStrings("d", byRank[3].member); + + const top2 = try ZSet.rangeByRank(&db, allocator, "z", -2, -1); + defer { + for (top2) |m| m.free(allocator); + allocator.free(top2); + } + try std.testing.expectEqual(@as(usize, 2), top2.len); + try std.testing.expectEqualStrings("c", top2[0].member); + try std.testing.expectEqualStrings("d", top2[1].member); + + const byScore = try ZSet.rangeByScore(&db, allocator, "z", -1.0, 50.0); + defer { + for (byScore) |m| m.free(allocator); + allocator.free(byScore); + } + try std.testing.expectEqual(@as(usize, 2), byScore.len); + try std.testing.expectEqualStrings("b", byScore[0].member); + try std.testing.expectEqualStrings("c", byScore[1].member); +} + +test "Kind.parse accepts the five CLI names and rejects the rest" { + try std.testing.expectEqual(Kind.counter, Kind.parse("counter").?); + try std.testing.expectEqual(Kind.hash, Kind.parse("hash").?); + try std.testing.expectEqual(Kind.set, Kind.parse("set").?); + try std.testing.expectEqual(Kind.list, Kind.parse("list").?); + try std.testing.expectEqual(Kind.zset, Kind.parse("zset").?); + try std.testing.expect(Kind.parse("bogus") == null); +} + +test "Kind.fromTagByte is the exact inverse of tagByte for all five kinds" { + const kinds = [_]Kind{ .counter, .hash, .set, .list, .zset }; + for (kinds) |k| { + try std.testing.expectEqual(k, Kind.fromTagByte(k.tagByte()).?); + } + try std.testing.expect(Kind.fromTagByte('?') == null); +} + +test "exists and drop cover counter (single key, no trailing separator)" { + const allocator = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var db = try openTemp(&tmp, allocator); + defer db.close(); + + try std.testing.expect(!try collectionExists(&db, allocator, .counter, "hits")); + _ = try Counter.incrBy(&db, allocator, "hits", 0); + try std.testing.expect(try collectionExists(&db, allocator, .counter, "hits")); + + // A same-prefixed name must not be swept up by the drop of "hits" - the + // counter key has no trailing separator, so this is the one case where a + // naive prefix scan (instead of an exact key) would over-delete. + _ = try Counter.incrBy(&db, allocator, "hitsExtra", 7); + + try std.testing.expectEqual(@as(usize, 1), try dropCollection(&db, allocator, .counter, "hits")); + try std.testing.expect(!try collectionExists(&db, allocator, .counter, "hits")); + try std.testing.expectEqual(@as(i64, 7), try Counter.get(&db, allocator, "hitsExtra")); +} + +test "drop removes every field of a hash and every element+meta of a list" { + const allocator = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var db = try openTemp(&tmp, allocator); + defer db.close(); + + _ = try Hash.set(&db, allocator, "user:1", "name", "ada"); + _ = try Hash.set(&db, allocator, "user:1", "age", "36"); + try std.testing.expect(try collectionExists(&db, allocator, .hash, "user:1")); + try std.testing.expectEqual(@as(usize, 2), try dropCollection(&db, allocator, .hash, "user:1")); + try std.testing.expect(!try collectionExists(&db, allocator, .hash, "user:1")); + try std.testing.expectEqual(@as(usize, 0), try Hash.len(&db, allocator, "user:1")); + + try List.pushRight(&db, allocator, "queue", "a"); + try List.pushRight(&db, allocator, "queue", "b"); + try std.testing.expect(try collectionExists(&db, allocator, .list, "queue")); + // 2 elements + 1 meta record. + try std.testing.expectEqual(@as(usize, 3), try dropCollection(&db, allocator, .list, "queue")); + try std.testing.expect(!try collectionExists(&db, allocator, .list, "queue")); + try std.testing.expectEqual(@as(usize, 0), try List.len(&db, allocator, "queue")); + + // Dropping something never created is a harmless no-op. + try std.testing.expectEqual(@as(usize, 0), try dropCollection(&db, allocator, .set, "never-existed")); +} + +test "drop removes both the member row and score-index row of every zset entry" { + const allocator = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var db = try openTemp(&tmp, allocator); + defer db.close(); + + try ZSet.add(&db, allocator, "leaderboard", "alice", 10); + try ZSet.add(&db, allocator, "leaderboard", "bob", 20); + try std.testing.expect(try collectionExists(&db, allocator, .zset, "leaderboard")); + // 2 members * (1 member row + 1 score-index row) = 4. + try std.testing.expectEqual(@as(usize, 4), try dropCollection(&db, allocator, .zset, "leaderboard")); + try std.testing.expect(!try collectionExists(&db, allocator, .zset, "leaderboard")); + try std.testing.expectEqual(@as(usize, 0), try ZSet.card(&db, allocator, "leaderboard")); +} + +test "zset add is a no-op when the score does not change" { + const allocator = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var db = try openTemp(&tmp, allocator); + defer db.close(); + + try ZSet.add(&db, allocator, "z", "a", 5.0); + try ZSet.add(&db, allocator, "z", "a", 5.0); + try std.testing.expectEqual(@as(usize, 1), try ZSet.card(&db, allocator, "z")); + try std.testing.expectEqual(@as(f64, 5.0), (try ZSet.score(&db, allocator, "z", "a")).?); +}