this repo has no description
at main 447 lines 18 kB view raw
1//! Redis sorted set (zset) commands 2//! 3//! Sorted sets combine the uniqueness of sets with a score for each member, 4//! keeping members sorted by score. This enables efficient range queries, 5//! rankings, and priority queues. 6//! 7//! ## Use Cases 8//! 9//! - Leaderboards: `ZADD leaderboard score player` 10//! - Time series (by timestamp): `ZADD events timestamp event_id` 11//! - Priority queues: `ZADD tasks priority task_id` 12//! - Rate limiting: `ZADD requests:user timestamp request_id` 13//! - Autocomplete: `ZADD suggestions 0 "search term"` 14//! 15//! ## Complexity 16//! 17//! - Add/remove/score lookup: O(log n) 18//! - Range by rank/score: O(log n + m) where m is elements returned 19//! - Cardinality: O(1) 20//! 21//! ## Examples 22//! 23//! ```zig 24//! // Leaderboard 25//! _ = try client.zsets().zadd("leaderboard", 100, "alice"); 26//! _ = try client.zsets().zadd("leaderboard", 95, "bob"); 27//! _ = try client.zsets().zincrBy("leaderboard", 10, "bob"); // bob now at 105 28//! 29//! // Get top 10 30//! const top = try client.zsets().zrangeWithScores("leaderboard", 0, 9, .rev); 31//! 32//! // Time-windowed events 33//! const now = std.time.timestamp(); 34//! _ = try client.zsets().zrangeByScore("events", now - 3600, now); // last hour 35//! ``` 36 37const std = @import("std"); 38const Client = @import("../client.zig").Client; 39const Value = @import("../resp.zig").Value; 40const CommandError = @import("../resp.zig").CommandError; 41const ClientError = @import("../resp.zig").ClientError; 42 43/// Sorted set command implementations. 44pub const SortedSetCommands = struct { 45 client: *Client, 46 47 pub fn init(client: *Client) SortedSetCommands { 48 return .{ .client = client }; 49 } 50 51 /// Score-member pair returned from WITHSCORES queries 52 pub const ScoredMember = struct { 53 member: []const u8, 54 score: f64, 55 }; 56 57 /// Options for ZADD command 58 pub const AddOptions = struct { 59 /// NX: only add new members (don't update existing) 60 nx: bool = false, 61 /// XX: only update existing members (don't add new) 62 xx: bool = false, 63 /// GT: only update if new score > current (Redis 6.2+) 64 gt: bool = false, 65 /// LT: only update if new score < current (Redis 6.2+) 66 lt: bool = false, 67 /// CH: return count of changed members (added + updated) 68 ch: bool = false, 69 }; 70 71 // ======================================================================== 72 // Basic Operations 73 // ======================================================================== 74 75 /// ZADD key score member [score member ...] - add member with score 76 /// Returns number of elements added. 77 pub fn zadd(self: *SortedSetCommands, key: []const u8, score: f64, member: []const u8) ClientError!i64 { 78 var buf: [32]u8 = undefined; 79 const score_str = std.fmt.bufPrint(&buf, "{d}", .{score}) catch unreachable; 80 const result = try self.client.sendCommand(&.{ "ZADD", key, score_str, member }); 81 return switch (result) { 82 .integer => |i| i, 83 .err => return CommandError.WrongType, 84 else => 0, 85 }; 86 } 87 88 /// ZADD with options and multiple members 89 pub fn zaddMulti(self: *SortedSetCommands, key: []const u8, options: AddOptions, pairs: []const struct { score: f64, member: []const u8 }) ClientError!i64 { 90 // Calculate arg count: ZADD key [options] score member [score member ...] 91 var opt_count: usize = 0; 92 if (options.nx) opt_count += 1; 93 if (options.xx) opt_count += 1; 94 if (options.gt) opt_count += 1; 95 if (options.lt) opt_count += 1; 96 if (options.ch) opt_count += 1; 97 98 const arg_count = 2 + opt_count + pairs.len * 2; 99 var args = try self.client.allocator.alloc([]const u8, arg_count); 100 defer self.client.allocator.free(args); 101 102 // We need to allocate score strings 103 var score_bufs = try self.client.allocator.alloc([32]u8, pairs.len); 104 defer self.client.allocator.free(score_bufs); 105 106 args[0] = "ZADD"; 107 args[1] = key; 108 109 var idx: usize = 2; 110 if (options.nx) { 111 args[idx] = "NX"; 112 idx += 1; 113 } 114 if (options.xx) { 115 args[idx] = "XX"; 116 idx += 1; 117 } 118 if (options.gt) { 119 args[idx] = "GT"; 120 idx += 1; 121 } 122 if (options.lt) { 123 args[idx] = "LT"; 124 idx += 1; 125 } 126 if (options.ch) { 127 args[idx] = "CH"; 128 idx += 1; 129 } 130 131 for (pairs, 0..) |pair, i| { 132 const score_str = std.fmt.bufPrint(&score_bufs[i], "{d}", .{pair.score}) catch unreachable; 133 args[idx] = score_str; 134 args[idx + 1] = pair.member; 135 idx += 2; 136 } 137 138 const result = try self.client.sendCommand(args); 139 return switch (result) { 140 .integer => |i| i, 141 .err => return CommandError.WrongType, 142 else => 0, 143 }; 144 } 145 146 /// ZREM key member [member ...] - remove members 147 pub fn zrem(self: *SortedSetCommands, key: []const u8, members: []const []const u8) ClientError!i64 { 148 var args = try self.client.allocator.alloc([]const u8, members.len + 2); 149 defer self.client.allocator.free(args); 150 args[0] = "ZREM"; 151 args[1] = key; 152 @memcpy(args[2..], members); 153 154 const result = try self.client.sendCommand(args); 155 return switch (result) { 156 .integer => |i| i, 157 else => 0, 158 }; 159 } 160 161 /// ZSCORE key member - get member's score 162 pub fn zscore(self: *SortedSetCommands, key: []const u8, member: []const u8) ClientError!?f64 { 163 const result = try self.client.sendCommand(&.{ "ZSCORE", key, member }); 164 return switch (result) { 165 .bulk => |b| if (b) |s| std.fmt.parseFloat(f64, s) catch null else null, 166 else => null, 167 }; 168 } 169 170 /// ZMSCORE key member [member ...] - get multiple scores (Redis 6.2+) 171 pub fn zmscore(self: *SortedSetCommands, key: []const u8, members: []const []const u8) ClientError![]const Value { 172 var args = try self.client.allocator.alloc([]const u8, members.len + 2); 173 defer self.client.allocator.free(args); 174 args[0] = "ZMSCORE"; 175 args[1] = key; 176 @memcpy(args[2..], members); 177 178 const result = try self.client.sendCommand(args); 179 return switch (result) { 180 .array => |a| a, 181 else => &.{}, 182 }; 183 } 184 185 /// ZINCRBY key increment member - increment score 186 pub fn zincrBy(self: *SortedSetCommands, key: []const u8, increment: f64, member: []const u8) ClientError!f64 { 187 var buf: [32]u8 = undefined; 188 const inc_str = std.fmt.bufPrint(&buf, "{d}", .{increment}) catch unreachable; 189 const result = try self.client.sendCommand(&.{ "ZINCRBY", key, inc_str, member }); 190 return switch (result) { 191 .bulk => |b| if (b) |s| std.fmt.parseFloat(f64, s) catch return CommandError.RedisError else return CommandError.RedisError, 192 .err => return CommandError.WrongType, 193 else => return CommandError.RedisError, 194 }; 195 } 196 197 /// ZCARD key - get cardinality 198 pub fn zcard(self: *SortedSetCommands, key: []const u8) ClientError!i64 { 199 const result = try self.client.sendCommand(&.{ "ZCARD", key }); 200 return switch (result) { 201 .integer => |i| i, 202 else => 0, 203 }; 204 } 205 206 /// ZCOUNT key min max - count members with score in range 207 pub fn zcount(self: *SortedSetCommands, key: []const u8, min: f64, max: f64) ClientError!i64 { 208 var min_buf: [32]u8 = undefined; 209 var max_buf: [32]u8 = undefined; 210 const min_str = std.fmt.bufPrint(&min_buf, "{d}", .{min}) catch unreachable; 211 const max_str = std.fmt.bufPrint(&max_buf, "{d}", .{max}) catch unreachable; 212 const result = try self.client.sendCommand(&.{ "ZCOUNT", key, min_str, max_str }); 213 return switch (result) { 214 .integer => |i| i, 215 else => 0, 216 }; 217 } 218 219 // ======================================================================== 220 // Rank Operations 221 // ======================================================================== 222 223 /// ZRANK key member - get rank (0-indexed, low to high) 224 pub fn zrank(self: *SortedSetCommands, key: []const u8, member: []const u8) ClientError!?i64 { 225 const result = try self.client.sendCommand(&.{ "ZRANK", key, member }); 226 return switch (result) { 227 .integer => |i| i, 228 .nil => null, 229 else => null, 230 }; 231 } 232 233 /// ZREVRANK key member - get reverse rank (0 = highest score) 234 pub fn zrevrank(self: *SortedSetCommands, key: []const u8, member: []const u8) ClientError!?i64 { 235 const result = try self.client.sendCommand(&.{ "ZREVRANK", key, member }); 236 return switch (result) { 237 .integer => |i| i, 238 .nil => null, 239 else => null, 240 }; 241 } 242 243 // ======================================================================== 244 // Range Queries 245 // ======================================================================== 246 247 /// Sort order for range queries 248 pub const Order = enum { asc, desc }; 249 250 /// ZRANGE key start stop [REV] - get members by rank 251 pub fn zrange(self: *SortedSetCommands, key: []const u8, start: i64, stop: i64, order: Order) ClientError![]const Value { 252 var start_buf: [24]u8 = undefined; 253 var stop_buf: [24]u8 = undefined; 254 const start_str = std.fmt.bufPrint(&start_buf, "{d}", .{start}) catch unreachable; 255 const stop_str = std.fmt.bufPrint(&stop_buf, "{d}", .{stop}) catch unreachable; 256 257 const result = if (order == .desc) 258 try self.client.sendCommand(&.{ "ZRANGE", key, start_str, stop_str, "REV" }) 259 else 260 try self.client.sendCommand(&.{ "ZRANGE", key, start_str, stop_str }); 261 262 return switch (result) { 263 .array => |a| a, 264 else => &.{}, 265 }; 266 } 267 268 /// ZRANGE key start stop WITHSCORES - get members with scores 269 pub fn zrangeWithScores(self: *SortedSetCommands, key: []const u8, start: i64, stop: i64, order: Order) ClientError![]const Value { 270 var start_buf: [24]u8 = undefined; 271 var stop_buf: [24]u8 = undefined; 272 const start_str = std.fmt.bufPrint(&start_buf, "{d}", .{start}) catch unreachable; 273 const stop_str = std.fmt.bufPrint(&stop_buf, "{d}", .{stop}) catch unreachable; 274 275 const result = if (order == .desc) 276 try self.client.sendCommand(&.{ "ZRANGE", key, start_str, stop_str, "REV", "WITHSCORES" }) 277 else 278 try self.client.sendCommand(&.{ "ZRANGE", key, start_str, stop_str, "WITHSCORES" }); 279 280 return switch (result) { 281 .array => |a| a, 282 else => &.{}, 283 }; 284 } 285 286 /// ZRANGEBYSCORE key min max - get members by score range 287 pub fn zrangeByScore(self: *SortedSetCommands, key: []const u8, min: f64, max: f64) ClientError![]const Value { 288 var min_buf: [32]u8 = undefined; 289 var max_buf: [32]u8 = undefined; 290 const min_str = std.fmt.bufPrint(&min_buf, "{d}", .{min}) catch unreachable; 291 const max_str = std.fmt.bufPrint(&max_buf, "{d}", .{max}) catch unreachable; 292 293 // ZRANGE with BYSCORE (Redis 6.2+) 294 const result = try self.client.sendCommand(&.{ "ZRANGE", key, min_str, max_str, "BYSCORE" }); 295 return switch (result) { 296 .array => |a| a, 297 else => &.{}, 298 }; 299 } 300 301 // ======================================================================== 302 // Pop Operations 303 // ======================================================================== 304 305 /// ZPOPMIN key [count] - remove and return lowest scored members 306 pub fn zpopmin(self: *SortedSetCommands, key: []const u8, count: ?u32) ClientError![]const Value { 307 if (count) |c| { 308 var buf: [16]u8 = undefined; 309 const count_str = std.fmt.bufPrint(&buf, "{d}", .{c}) catch unreachable; 310 const result = try self.client.sendCommand(&.{ "ZPOPMIN", key, count_str }); 311 return switch (result) { 312 .array => |a| a, 313 else => &.{}, 314 }; 315 } else { 316 const result = try self.client.sendCommand(&.{ "ZPOPMIN", key }); 317 return switch (result) { 318 .array => |a| a, 319 else => &.{}, 320 }; 321 } 322 } 323 324 /// ZPOPMAX key [count] - remove and return highest scored members 325 pub fn zpopmax(self: *SortedSetCommands, key: []const u8, count: ?u32) ClientError![]const Value { 326 if (count) |c| { 327 var buf: [16]u8 = undefined; 328 const count_str = std.fmt.bufPrint(&buf, "{d}", .{c}) catch unreachable; 329 const result = try self.client.sendCommand(&.{ "ZPOPMAX", key, count_str }); 330 return switch (result) { 331 .array => |a| a, 332 else => &.{}, 333 }; 334 } else { 335 const result = try self.client.sendCommand(&.{ "ZPOPMAX", key }); 336 return switch (result) { 337 .array => |a| a, 338 else => &.{}, 339 }; 340 } 341 } 342 343 /// BZPOPMIN key [key ...] timeout - blocking pop min 344 pub fn bzpopmin(self: *SortedSetCommands, keys: []const []const u8, timeout: f64) ClientError!?struct { key: []const u8, member: []const u8, score: f64 } { 345 var args = try self.client.allocator.alloc([]const u8, keys.len + 2); 346 defer self.client.allocator.free(args); 347 348 args[0] = "BZPOPMIN"; 349 @memcpy(args[1..][0..keys.len], keys); 350 351 var buf: [32]u8 = undefined; 352 args[keys.len + 1] = std.fmt.bufPrint(&buf, "{d}", .{timeout}) catch unreachable; 353 354 const result = try self.client.sendCommand(args); 355 return switch (result) { 356 .array => |arr| { 357 if (arr.len < 3) return null; 358 const k = arr[0].asString() orelse return null; 359 const m = arr[1].asString() orelse return null; 360 const s = if (arr[2].asString()) |str| std.fmt.parseFloat(f64, str) catch return null else return null; 361 return .{ .key = k, .member = m, .score = s }; 362 }, 363 .nil => null, 364 else => null, 365 }; 366 } 367 368 // ======================================================================== 369 // Removal by Range 370 // ======================================================================== 371 372 /// ZREMRANGEBYRANK key start stop - remove by rank range 373 pub fn zremrangeByRank(self: *SortedSetCommands, key: []const u8, start: i64, stop: i64) ClientError!i64 { 374 var start_buf: [24]u8 = undefined; 375 var stop_buf: [24]u8 = undefined; 376 const start_str = std.fmt.bufPrint(&start_buf, "{d}", .{start}) catch unreachable; 377 const stop_str = std.fmt.bufPrint(&stop_buf, "{d}", .{stop}) catch unreachable; 378 const result = try self.client.sendCommand(&.{ "ZREMRANGEBYRANK", key, start_str, stop_str }); 379 return switch (result) { 380 .integer => |i| i, 381 else => 0, 382 }; 383 } 384 385 /// ZREMRANGEBYSCORE key min max - remove by score range 386 pub fn zremrangeByScore(self: *SortedSetCommands, key: []const u8, min: f64, max: f64) ClientError!i64 { 387 var min_buf: [32]u8 = undefined; 388 var max_buf: [32]u8 = undefined; 389 const min_str = std.fmt.bufPrint(&min_buf, "{d}", .{min}) catch unreachable; 390 const max_str = std.fmt.bufPrint(&max_buf, "{d}", .{max}) catch unreachable; 391 const result = try self.client.sendCommand(&.{ "ZREMRANGEBYSCORE", key, min_str, max_str }); 392 return switch (result) { 393 .integer => |i| i, 394 else => 0, 395 }; 396 } 397 398 // ======================================================================== 399 // Scanning 400 // ======================================================================== 401 402 /// Result from ZSCAN command 403 pub const ScanResult = struct { 404 cursor: []const u8, 405 /// Flat array: [member1, score1, member2, score2, ...] 406 pairs: []const Value, 407 }; 408 409 /// ZSCAN key cursor [MATCH pattern] [COUNT count] - iterate members 410 pub fn zscan(self: *SortedSetCommands, key: []const u8, cursor: []const u8, pattern: ?[]const u8, count: ?u32) ClientError!ScanResult { 411 var args_buf: [8][]const u8 = undefined; 412 var arg_count: usize = 3; 413 args_buf[0] = "ZSCAN"; 414 args_buf[1] = key; 415 args_buf[2] = cursor; 416 417 if (pattern) |p| { 418 args_buf[arg_count] = "MATCH"; 419 args_buf[arg_count + 1] = p; 420 arg_count += 2; 421 } 422 423 var count_buf: [16]u8 = undefined; 424 if (count) |c| { 425 args_buf[arg_count] = "COUNT"; 426 args_buf[arg_count + 1] = std.fmt.bufPrint(&count_buf, "{d}", .{c}) catch unreachable; 427 arg_count += 2; 428 } 429 430 const result = try self.client.sendCommand(args_buf[0..arg_count]); 431 return switch (result) { 432 .array => |arr| { 433 if (arr.len < 2) return ScanResult{ .cursor = "0", .pairs = &.{} }; 434 return ScanResult{ 435 .cursor = arr[0].asString() orelse "0", 436 .pairs = arr[1].asArray() orelse &.{}, 437 }; 438 }, 439 else => ScanResult{ .cursor = "0", .pairs = &.{} }, 440 }; 441 } 442}; 443 444/// Extend Client with sorted set commands. 445pub fn zsets(client: *Client) SortedSetCommands { 446 return SortedSetCommands.init(client); 447}