//! Redis sorted set (zset) commands //! //! Sorted sets combine the uniqueness of sets with a score for each member, //! keeping members sorted by score. This enables efficient range queries, //! rankings, and priority queues. //! //! ## Use Cases //! //! - Leaderboards: `ZADD leaderboard score player` //! - Time series (by timestamp): `ZADD events timestamp event_id` //! - Priority queues: `ZADD tasks priority task_id` //! - Rate limiting: `ZADD requests:user timestamp request_id` //! - Autocomplete: `ZADD suggestions 0 "search term"` //! //! ## Complexity //! //! - Add/remove/score lookup: O(log n) //! - Range by rank/score: O(log n + m) where m is elements returned //! - Cardinality: O(1) //! //! ## Examples //! //! ```zig //! // Leaderboard //! _ = try client.zsets().zadd("leaderboard", 100, "alice"); //! _ = try client.zsets().zadd("leaderboard", 95, "bob"); //! _ = try client.zsets().zincrBy("leaderboard", 10, "bob"); // bob now at 105 //! //! // Get top 10 //! const top = try client.zsets().zrangeWithScores("leaderboard", 0, 9, .rev); //! //! // Time-windowed events //! const now = std.time.timestamp(); //! _ = try client.zsets().zrangeByScore("events", now - 3600, now); // last hour //! ``` const std = @import("std"); const Client = @import("../client.zig").Client; const Value = @import("../resp.zig").Value; const CommandError = @import("../resp.zig").CommandError; const ClientError = @import("../resp.zig").ClientError; /// Sorted set command implementations. pub const SortedSetCommands = struct { client: *Client, pub fn init(client: *Client) SortedSetCommands { return .{ .client = client }; } /// Score-member pair returned from WITHSCORES queries pub const ScoredMember = struct { member: []const u8, score: f64, }; /// Options for ZADD command pub const AddOptions = struct { /// NX: only add new members (don't update existing) nx: bool = false, /// XX: only update existing members (don't add new) xx: bool = false, /// GT: only update if new score > current (Redis 6.2+) gt: bool = false, /// LT: only update if new score < current (Redis 6.2+) lt: bool = false, /// CH: return count of changed members (added + updated) ch: bool = false, }; // ======================================================================== // Basic Operations // ======================================================================== /// ZADD key score member [score member ...] - add member with score /// Returns number of elements added. pub fn zadd(self: *SortedSetCommands, key: []const u8, score: f64, member: []const u8) ClientError!i64 { var buf: [32]u8 = undefined; const score_str = std.fmt.bufPrint(&buf, "{d}", .{score}) catch unreachable; const result = try self.client.sendCommand(&.{ "ZADD", key, score_str, member }); return switch (result) { .integer => |i| i, .err => return CommandError.WrongType, else => 0, }; } /// ZADD with options and multiple members pub fn zaddMulti(self: *SortedSetCommands, key: []const u8, options: AddOptions, pairs: []const struct { score: f64, member: []const u8 }) ClientError!i64 { // Calculate arg count: ZADD key [options] score member [score member ...] var opt_count: usize = 0; if (options.nx) opt_count += 1; if (options.xx) opt_count += 1; if (options.gt) opt_count += 1; if (options.lt) opt_count += 1; if (options.ch) opt_count += 1; const arg_count = 2 + opt_count + pairs.len * 2; var args = try self.client.allocator.alloc([]const u8, arg_count); defer self.client.allocator.free(args); // We need to allocate score strings var score_bufs = try self.client.allocator.alloc([32]u8, pairs.len); defer self.client.allocator.free(score_bufs); args[0] = "ZADD"; args[1] = key; var idx: usize = 2; if (options.nx) { args[idx] = "NX"; idx += 1; } if (options.xx) { args[idx] = "XX"; idx += 1; } if (options.gt) { args[idx] = "GT"; idx += 1; } if (options.lt) { args[idx] = "LT"; idx += 1; } if (options.ch) { args[idx] = "CH"; idx += 1; } for (pairs, 0..) |pair, i| { const score_str = std.fmt.bufPrint(&score_bufs[i], "{d}", .{pair.score}) catch unreachable; args[idx] = score_str; args[idx + 1] = pair.member; idx += 2; } const result = try self.client.sendCommand(args); return switch (result) { .integer => |i| i, .err => return CommandError.WrongType, else => 0, }; } /// ZREM key member [member ...] - remove members pub fn zrem(self: *SortedSetCommands, key: []const u8, members: []const []const u8) ClientError!i64 { var args = try self.client.allocator.alloc([]const u8, members.len + 2); defer self.client.allocator.free(args); args[0] = "ZREM"; args[1] = key; @memcpy(args[2..], members); const result = try self.client.sendCommand(args); return switch (result) { .integer => |i| i, else => 0, }; } /// ZSCORE key member - get member's score pub fn zscore(self: *SortedSetCommands, key: []const u8, member: []const u8) ClientError!?f64 { const result = try self.client.sendCommand(&.{ "ZSCORE", key, member }); return switch (result) { .bulk => |b| if (b) |s| std.fmt.parseFloat(f64, s) catch null else null, else => null, }; } /// ZMSCORE key member [member ...] - get multiple scores (Redis 6.2+) pub fn zmscore(self: *SortedSetCommands, key: []const u8, members: []const []const u8) ClientError![]const Value { var args = try self.client.allocator.alloc([]const u8, members.len + 2); defer self.client.allocator.free(args); args[0] = "ZMSCORE"; args[1] = key; @memcpy(args[2..], members); const result = try self.client.sendCommand(args); return switch (result) { .array => |a| a, else => &.{}, }; } /// ZINCRBY key increment member - increment score pub fn zincrBy(self: *SortedSetCommands, key: []const u8, increment: f64, member: []const u8) ClientError!f64 { var buf: [32]u8 = undefined; const inc_str = std.fmt.bufPrint(&buf, "{d}", .{increment}) catch unreachable; const result = try self.client.sendCommand(&.{ "ZINCRBY", key, inc_str, member }); return switch (result) { .bulk => |b| if (b) |s| std.fmt.parseFloat(f64, s) catch return CommandError.RedisError else return CommandError.RedisError, .err => return CommandError.WrongType, else => return CommandError.RedisError, }; } /// ZCARD key - get cardinality pub fn zcard(self: *SortedSetCommands, key: []const u8) ClientError!i64 { const result = try self.client.sendCommand(&.{ "ZCARD", key }); return switch (result) { .integer => |i| i, else => 0, }; } /// ZCOUNT key min max - count members with score in range pub fn zcount(self: *SortedSetCommands, key: []const u8, min: f64, max: f64) ClientError!i64 { var min_buf: [32]u8 = undefined; var max_buf: [32]u8 = undefined; const min_str = std.fmt.bufPrint(&min_buf, "{d}", .{min}) catch unreachable; const max_str = std.fmt.bufPrint(&max_buf, "{d}", .{max}) catch unreachable; const result = try self.client.sendCommand(&.{ "ZCOUNT", key, min_str, max_str }); return switch (result) { .integer => |i| i, else => 0, }; } // ======================================================================== // Rank Operations // ======================================================================== /// ZRANK key member - get rank (0-indexed, low to high) pub fn zrank(self: *SortedSetCommands, key: []const u8, member: []const u8) ClientError!?i64 { const result = try self.client.sendCommand(&.{ "ZRANK", key, member }); return switch (result) { .integer => |i| i, .nil => null, else => null, }; } /// ZREVRANK key member - get reverse rank (0 = highest score) pub fn zrevrank(self: *SortedSetCommands, key: []const u8, member: []const u8) ClientError!?i64 { const result = try self.client.sendCommand(&.{ "ZREVRANK", key, member }); return switch (result) { .integer => |i| i, .nil => null, else => null, }; } // ======================================================================== // Range Queries // ======================================================================== /// Sort order for range queries pub const Order = enum { asc, desc }; /// ZRANGE key start stop [REV] - get members by rank pub fn zrange(self: *SortedSetCommands, key: []const u8, start: i64, stop: i64, order: Order) ClientError![]const Value { var start_buf: [24]u8 = undefined; var stop_buf: [24]u8 = undefined; const start_str = std.fmt.bufPrint(&start_buf, "{d}", .{start}) catch unreachable; const stop_str = std.fmt.bufPrint(&stop_buf, "{d}", .{stop}) catch unreachable; const result = if (order == .desc) try self.client.sendCommand(&.{ "ZRANGE", key, start_str, stop_str, "REV" }) else try self.client.sendCommand(&.{ "ZRANGE", key, start_str, stop_str }); return switch (result) { .array => |a| a, else => &.{}, }; } /// ZRANGE key start stop WITHSCORES - get members with scores pub fn zrangeWithScores(self: *SortedSetCommands, key: []const u8, start: i64, stop: i64, order: Order) ClientError![]const Value { var start_buf: [24]u8 = undefined; var stop_buf: [24]u8 = undefined; const start_str = std.fmt.bufPrint(&start_buf, "{d}", .{start}) catch unreachable; const stop_str = std.fmt.bufPrint(&stop_buf, "{d}", .{stop}) catch unreachable; const result = if (order == .desc) try self.client.sendCommand(&.{ "ZRANGE", key, start_str, stop_str, "REV", "WITHSCORES" }) else try self.client.sendCommand(&.{ "ZRANGE", key, start_str, stop_str, "WITHSCORES" }); return switch (result) { .array => |a| a, else => &.{}, }; } /// ZRANGEBYSCORE key min max - get members by score range pub fn zrangeByScore(self: *SortedSetCommands, key: []const u8, min: f64, max: f64) ClientError![]const Value { var min_buf: [32]u8 = undefined; var max_buf: [32]u8 = undefined; const min_str = std.fmt.bufPrint(&min_buf, "{d}", .{min}) catch unreachable; const max_str = std.fmt.bufPrint(&max_buf, "{d}", .{max}) catch unreachable; // ZRANGE with BYSCORE (Redis 6.2+) const result = try self.client.sendCommand(&.{ "ZRANGE", key, min_str, max_str, "BYSCORE" }); return switch (result) { .array => |a| a, else => &.{}, }; } // ======================================================================== // Pop Operations // ======================================================================== /// ZPOPMIN key [count] - remove and return lowest scored members pub fn zpopmin(self: *SortedSetCommands, key: []const u8, count: ?u32) ClientError![]const Value { if (count) |c| { var buf: [16]u8 = undefined; const count_str = std.fmt.bufPrint(&buf, "{d}", .{c}) catch unreachable; const result = try self.client.sendCommand(&.{ "ZPOPMIN", key, count_str }); return switch (result) { .array => |a| a, else => &.{}, }; } else { const result = try self.client.sendCommand(&.{ "ZPOPMIN", key }); return switch (result) { .array => |a| a, else => &.{}, }; } } /// ZPOPMAX key [count] - remove and return highest scored members pub fn zpopmax(self: *SortedSetCommands, key: []const u8, count: ?u32) ClientError![]const Value { if (count) |c| { var buf: [16]u8 = undefined; const count_str = std.fmt.bufPrint(&buf, "{d}", .{c}) catch unreachable; const result = try self.client.sendCommand(&.{ "ZPOPMAX", key, count_str }); return switch (result) { .array => |a| a, else => &.{}, }; } else { const result = try self.client.sendCommand(&.{ "ZPOPMAX", key }); return switch (result) { .array => |a| a, else => &.{}, }; } } /// BZPOPMIN key [key ...] timeout - blocking pop min pub fn bzpopmin(self: *SortedSetCommands, keys: []const []const u8, timeout: f64) ClientError!?struct { key: []const u8, member: []const u8, score: f64 } { var args = try self.client.allocator.alloc([]const u8, keys.len + 2); defer self.client.allocator.free(args); args[0] = "BZPOPMIN"; @memcpy(args[1..][0..keys.len], keys); var buf: [32]u8 = undefined; args[keys.len + 1] = std.fmt.bufPrint(&buf, "{d}", .{timeout}) catch unreachable; const result = try self.client.sendCommand(args); return switch (result) { .array => |arr| { if (arr.len < 3) return null; const k = arr[0].asString() orelse return null; const m = arr[1].asString() orelse return null; const s = if (arr[2].asString()) |str| std.fmt.parseFloat(f64, str) catch return null else return null; return .{ .key = k, .member = m, .score = s }; }, .nil => null, else => null, }; } // ======================================================================== // Removal by Range // ======================================================================== /// ZREMRANGEBYRANK key start stop - remove by rank range pub fn zremrangeByRank(self: *SortedSetCommands, key: []const u8, start: i64, stop: i64) ClientError!i64 { var start_buf: [24]u8 = undefined; var stop_buf: [24]u8 = undefined; const start_str = std.fmt.bufPrint(&start_buf, "{d}", .{start}) catch unreachable; const stop_str = std.fmt.bufPrint(&stop_buf, "{d}", .{stop}) catch unreachable; const result = try self.client.sendCommand(&.{ "ZREMRANGEBYRANK", key, start_str, stop_str }); return switch (result) { .integer => |i| i, else => 0, }; } /// ZREMRANGEBYSCORE key min max - remove by score range pub fn zremrangeByScore(self: *SortedSetCommands, key: []const u8, min: f64, max: f64) ClientError!i64 { var min_buf: [32]u8 = undefined; var max_buf: [32]u8 = undefined; const min_str = std.fmt.bufPrint(&min_buf, "{d}", .{min}) catch unreachable; const max_str = std.fmt.bufPrint(&max_buf, "{d}", .{max}) catch unreachable; const result = try self.client.sendCommand(&.{ "ZREMRANGEBYSCORE", key, min_str, max_str }); return switch (result) { .integer => |i| i, else => 0, }; } // ======================================================================== // Scanning // ======================================================================== /// Result from ZSCAN command pub const ScanResult = struct { cursor: []const u8, /// Flat array: [member1, score1, member2, score2, ...] pairs: []const Value, }; /// ZSCAN key cursor [MATCH pattern] [COUNT count] - iterate members pub fn zscan(self: *SortedSetCommands, key: []const u8, cursor: []const u8, pattern: ?[]const u8, count: ?u32) ClientError!ScanResult { var args_buf: [8][]const u8 = undefined; var arg_count: usize = 3; args_buf[0] = "ZSCAN"; args_buf[1] = key; args_buf[2] = cursor; if (pattern) |p| { args_buf[arg_count] = "MATCH"; args_buf[arg_count + 1] = p; arg_count += 2; } var count_buf: [16]u8 = undefined; if (count) |c| { args_buf[arg_count] = "COUNT"; args_buf[arg_count + 1] = std.fmt.bufPrint(&count_buf, "{d}", .{c}) catch unreachable; arg_count += 2; } const result = try self.client.sendCommand(args_buf[0..arg_count]); return switch (result) { .array => |arr| { if (arr.len < 2) return ScanResult{ .cursor = "0", .pairs = &.{} }; return ScanResult{ .cursor = arr[0].asString() orelse "0", .pairs = arr[1].asArray() orelse &.{}, }; }, else => ScanResult{ .cursor = "0", .pairs = &.{} }, }; } }; /// Extend Client with sorted set commands. pub fn zsets(client: *Client) SortedSetCommands { return SortedSetCommands.init(client); }