hotkv 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +202 -0
  3. data/NOTICE +6 -0
  4. data/README.md +459 -0
  5. data/SECURITY.md +30 -0
  6. data/lib/hotkv/ai/call_result.rb +25 -0
  7. data/lib/hotkv/ai/prompt_cache.rb +135 -0
  8. data/lib/hotkv/ai/semantic_cache.rb +66 -0
  9. data/lib/hotkv/client.rb +201 -0
  10. data/lib/hotkv/cluster/crc16.rb +43 -0
  11. data/lib/hotkv/cluster.rb +184 -0
  12. data/lib/hotkv/commands/admin.rb +36 -0
  13. data/lib/hotkv/commands/all.rb +49 -0
  14. data/lib/hotkv/commands/bitmaps.rb +52 -0
  15. data/lib/hotkv/commands/geo.rb +146 -0
  16. data/lib/hotkv/commands/hashes.rb +169 -0
  17. data/lib/hotkv/commands/hyperloglog.rb +21 -0
  18. data/lib/hotkv/commands/keys.rb +170 -0
  19. data/lib/hotkv/commands/lists.rb +113 -0
  20. data/lib/hotkv/commands/probabilistic.rb +23 -0
  21. data/lib/hotkv/commands/publishing.rb +46 -0
  22. data/lib/hotkv/commands/scan.rb +82 -0
  23. data/lib/hotkv/commands/scripting.rb +128 -0
  24. data/lib/hotkv/commands/server.rb +175 -0
  25. data/lib/hotkv/commands/sets.rb +75 -0
  26. data/lib/hotkv/commands/sorted_sets.rb +258 -0
  27. data/lib/hotkv/commands/streams.rb +207 -0
  28. data/lib/hotkv/commands/strings.rb +178 -0
  29. data/lib/hotkv/commands/support.rb +69 -0
  30. data/lib/hotkv/connection.rb +221 -0
  31. data/lib/hotkv/errors.rb +145 -0
  32. data/lib/hotkv/extensions.rb +118 -0
  33. data/lib/hotkv/future.rb +55 -0
  34. data/lib/hotkv/llm_price.rb +53 -0
  35. data/lib/hotkv/namespaces/admin.rb +261 -0
  36. data/lib/hotkv/namespaces/amem.rb +172 -0
  37. data/lib/hotkv/namespaces/caching.rb +397 -0
  38. data/lib/hotkv/namespaces/core.rb +190 -0
  39. data/lib/hotkv/namespaces/ops.rb +166 -0
  40. data/lib/hotkv/namespaces/probabilistic.rb +180 -0
  41. data/lib/hotkv/namespaces/rag.rb +63 -0
  42. data/lib/hotkv/pipeline.rb +57 -0
  43. data/lib/hotkv/pool.rb +123 -0
  44. data/lib/hotkv/pubsub.rb +60 -0
  45. data/lib/hotkv/resp.rb +176 -0
  46. data/lib/hotkv/transaction.rb +115 -0
  47. data/lib/hotkv/url.rb +100 -0
  48. data/lib/hotkv/usage.rb +42 -0
  49. data/lib/hotkv/version.rb +7 -0
  50. data/lib/hotkv/wire.rb +145 -0
  51. data/lib/hotkv.rb +28 -0
  52. metadata +137 -0
@@ -0,0 +1,258 @@
1
+ # Copyright (c) 2026 HotKV Ltd
2
+ # Licensed under the Apache License, Version 2.0. See LICENSE in the project root.
3
+
4
+ module HotKV
5
+ module Commands
6
+ # Sorted set commands, including the BYSCORE/BYLEX/REV/LIMIT ZRANGE
7
+ # family and the RESP3 array-of-pairs WITHSCORES shape.
8
+ module SortedSets
9
+ # +score_members+ is a Hash {member => score}, or a flat/paired list
10
+ # of scores and members: zadd("k", 1, "a", 2, "b") or
11
+ # zadd("k", [1, "a"], [2, "b"]).
12
+ def zadd(key, *score_members, nx: false, xx: false, gt: false, lt: false, ch: false, incr: false)
13
+ args = ["ZADD", key]
14
+ args.push("NX") if nx
15
+ args.push("XX") if xx
16
+ args.push("GT") if gt
17
+ args.push("LT") if lt
18
+ args.push("CH") if ch
19
+ args.push("INCR") if incr
20
+ args.concat(flatten_score_members(score_members))
21
+ invoke(*args) { |r| incr ? (r.nil? ? nil : Wire.parse_float(r)) : r }
22
+ end
23
+
24
+ def zrem(key, *members)
25
+ invoke("ZREM", key, *members.flatten)
26
+ end
27
+
28
+ def zscore(key, member)
29
+ invoke("ZSCORE", key, member) { |r| r.nil? ? nil : Wire.parse_float(r) }
30
+ end
31
+
32
+ def zmscore(key, *members)
33
+ invoke("ZMSCORE", key, *members.flatten) { |r| r.map { |v| v.nil? ? nil : Wire.parse_float(v) } }
34
+ end
35
+
36
+ def zcard(key)
37
+ invoke("ZCARD", key)
38
+ end
39
+
40
+ def zcount(key, min, max)
41
+ invoke("ZCOUNT", key, min, max)
42
+ end
43
+
44
+ def zincrby(key, increment, member)
45
+ invoke("ZINCRBY", key, increment, member) { |r| Wire.parse_float(r) }
46
+ end
47
+
48
+ # Modern ZRANGE, covering plain index range, BYSCORE, BYLEX, REV and
49
+ # LIMIT (an [offset, count] pair, only valid with BYSCORE/BYLEX).
50
+ # With :with_scores, returns an Array of [member, score] pairs
51
+ # (paired up uniformly regardless of the RESP2/RESP3 wire shape).
52
+ def zrange(key, start, stop, byscore: false, bylex: false, rev: false, limit: nil, with_scores: false)
53
+ args = ["ZRANGE", key, start, stop]
54
+ args.push("BYSCORE") if byscore
55
+ args.push("BYLEX") if bylex
56
+ args.push("REV") if rev
57
+ args.push("LIMIT", limit[0], limit[1]) if limit
58
+ args.push("WITHSCORES") if with_scores
59
+ invoke(*args) { |r| with_scores ? Support.pairify(r) : r }
60
+ end
61
+
62
+ def zrangestore(destination, source, start, stop, byscore: false, bylex: false, rev: false, limit: nil)
63
+ args = ["ZRANGESTORE", destination, source, start, stop]
64
+ args.push("BYSCORE") if byscore
65
+ args.push("BYLEX") if bylex
66
+ args.push("REV") if rev
67
+ args.push("LIMIT", limit[0], limit[1]) if limit
68
+ invoke(*args)
69
+ end
70
+
71
+ def zrevrange(key, start, stop, with_scores: false)
72
+ args = ["ZREVRANGE", key, start, stop]
73
+ args.push("WITHSCORES") if with_scores
74
+ invoke(*args) { |r| with_scores ? Support.pairify(r) : r }
75
+ end
76
+
77
+ def zrangebyscore(key, min, max, with_scores: false, limit: nil)
78
+ args = ["ZRANGEBYSCORE", key, min, max]
79
+ args.push("LIMIT", limit[0], limit[1]) if limit
80
+ args.push("WITHSCORES") if with_scores
81
+ invoke(*args) { |r| with_scores ? Support.pairify(r) : r }
82
+ end
83
+
84
+ def zrevrangebyscore(key, max, min, with_scores: false, limit: nil)
85
+ args = ["ZREVRANGEBYSCORE", key, max, min]
86
+ args.push("LIMIT", limit[0], limit[1]) if limit
87
+ args.push("WITHSCORES") if with_scores
88
+ invoke(*args) { |r| with_scores ? Support.pairify(r) : r }
89
+ end
90
+
91
+ def zrangebylex(key, min, max, limit: nil)
92
+ args = ["ZRANGEBYLEX", key, min, max]
93
+ args.push("LIMIT", limit[0], limit[1]) if limit
94
+ invoke(*args)
95
+ end
96
+
97
+ def zrevrangebylex(key, max, min, limit: nil)
98
+ args = ["ZREVRANGEBYLEX", key, max, min]
99
+ args.push("LIMIT", limit[0], limit[1]) if limit
100
+ invoke(*args)
101
+ end
102
+
103
+ def zlexcount(key, min, max)
104
+ invoke("ZLEXCOUNT", key, min, max)
105
+ end
106
+
107
+ def zrank(key, member, with_score: false)
108
+ args = ["ZRANK", key, member]
109
+ args.push("WITHSCORE") if with_score
110
+ invoke(*args) { |r| with_score ? (r.nil? ? nil : [r[0], Wire.parse_float(r[1])]) : r }
111
+ end
112
+
113
+ def zrevrank(key, member, with_score: false)
114
+ args = ["ZREVRANK", key, member]
115
+ args.push("WITHSCORE") if with_score
116
+ invoke(*args) { |r| with_score ? (r.nil? ? nil : [r[0], Wire.parse_float(r[1])]) : r }
117
+ end
118
+
119
+ # No :count -> a single [member, score] Array, or nil if empty.
120
+ # With :count -> an Array of [member, score] pairs (possibly empty).
121
+ def zpopmin(key, count = nil)
122
+ pop("ZPOPMIN", key, count)
123
+ end
124
+
125
+ def zpopmax(key, count = nil)
126
+ pop("ZPOPMAX", key, count)
127
+ end
128
+
129
+ def zrandmember(key, count = nil, withscores: false)
130
+ return invoke("ZRANDMEMBER", key) if count.nil?
131
+
132
+ args = ["ZRANDMEMBER", key, count]
133
+ args.push("WITHSCORES") if withscores
134
+ invoke(*args) { |r| withscores ? Support.pairify(r) : r }
135
+ end
136
+
137
+ def zremrangebyrank(key, start, stop)
138
+ invoke("ZREMRANGEBYRANK", key, start, stop)
139
+ end
140
+
141
+ def zremrangebyscore(key, min, max)
142
+ invoke("ZREMRANGEBYSCORE", key, min, max)
143
+ end
144
+
145
+ def zremrangebylex(key, min, max)
146
+ invoke("ZREMRANGEBYLEX", key, min, max)
147
+ end
148
+
149
+ def zunionstore(destination, keys, weights: nil, aggregate: nil)
150
+ invoke(*zstore_args("ZUNIONSTORE", destination, keys, weights, aggregate))
151
+ end
152
+
153
+ def zinterstore(destination, keys, weights: nil, aggregate: nil)
154
+ invoke(*zstore_args("ZINTERSTORE", destination, keys, weights, aggregate))
155
+ end
156
+
157
+ def zdiffstore(destination, keys)
158
+ invoke("ZDIFFSTORE", destination, keys.length, *keys)
159
+ end
160
+
161
+ def zunion(keys, weights: nil, aggregate: nil, with_scores: false)
162
+ invoke(*zset_op_args("ZUNION", keys, weights, aggregate, with_scores)) do |r|
163
+ with_scores ? Support.pairify(r) : r
164
+ end
165
+ end
166
+
167
+ def zinter(keys, weights: nil, aggregate: nil, with_scores: false)
168
+ invoke(*zset_op_args("ZINTER", keys, weights, aggregate, with_scores)) do |r|
169
+ with_scores ? Support.pairify(r) : r
170
+ end
171
+ end
172
+
173
+ def zdiff(keys, with_scores: false)
174
+ args = ["ZDIFF", keys.length, *keys]
175
+ args.push("WITHSCORES") if with_scores
176
+ invoke(*args) { |r| with_scores ? Support.pairify(r) : r }
177
+ end
178
+
179
+ def zintercard(keys, limit: nil)
180
+ args = ["ZINTERCARD", keys.length, *keys]
181
+ args.push("LIMIT", limit) if limit
182
+ invoke(*args)
183
+ end
184
+
185
+ # Pops from the first non-empty sorted set of +keys+ (ZMPOP). +where+ is
186
+ # "MIN" or "MAX". Returns [key, [[member, score], ...]] or nil.
187
+ def zmpop(keys, where, count: nil)
188
+ args = ["ZMPOP", keys.length, *keys, where]
189
+ args.push("COUNT", count) if count
190
+ invoke(*args) { |r| zmpop_reply(r) }
191
+ end
192
+
193
+ # Blocking ZMPOP: waits up to +timeout+ seconds. Same reply as #zmpop.
194
+ def bzmpop(keys, where, timeout: 0, count: nil)
195
+ args = ["BZMPOP", timeout, keys.length, *keys, where]
196
+ args.push("COUNT", count) if count
197
+ invoke(*args) { |r| zmpop_reply(r) }
198
+ end
199
+
200
+ # Blocking ZPOPMIN across +keys+. Returns [key, member, score] or nil.
201
+ def bzpopmin(*keys, timeout: 0)
202
+ invoke("BZPOPMIN", *keys.flatten, timeout) { |r| bzpop_reply(r) }
203
+ end
204
+
205
+ # Blocking ZPOPMAX across +keys+. Returns [key, member, score] or nil.
206
+ def bzpopmax(*keys, timeout: 0)
207
+ invoke("BZPOPMAX", *keys.flatten, timeout) { |r| bzpop_reply(r) }
208
+ end
209
+
210
+ private
211
+
212
+ def zmpop_reply(reply)
213
+ return nil if reply.nil?
214
+
215
+ key, pairs = reply
216
+ [key, pairs.map { |(member, score)| [member, Wire.parse_float(score)] }]
217
+ end
218
+
219
+ def bzpop_reply(reply)
220
+ return nil if reply.nil?
221
+
222
+ key, member, score = reply
223
+ [key, member, Wire.parse_float(score)]
224
+ end
225
+
226
+ def pop(cmd, key, count)
227
+ if count
228
+ invoke(cmd, key, count) { |r| Support.pairify(r) }
229
+ else
230
+ invoke(cmd, key) { |r| (r.nil? || r.empty?) ? nil : [r[0], Wire.parse_float(r[1])] }
231
+ end
232
+ end
233
+
234
+ def flatten_score_members(args)
235
+ if args.length == 1 && args.first.is_a?(Hash)
236
+ args.first.flat_map { |member, score| [score, member] }
237
+ else
238
+ args.flatten
239
+ end
240
+ end
241
+
242
+ def zstore_args(cmd, destination, keys, weights, aggregate)
243
+ args = [cmd, destination, keys.length, *keys]
244
+ args.push("WEIGHTS", *weights) if weights
245
+ args.push("AGGREGATE", aggregate) if aggregate
246
+ args
247
+ end
248
+
249
+ def zset_op_args(cmd, keys, weights, aggregate, with_scores)
250
+ args = [cmd, keys.length, *keys]
251
+ args.push("WEIGHTS", *weights) if weights
252
+ args.push("AGGREGATE", aggregate) if aggregate
253
+ args.push("WITHSCORES") if with_scores
254
+ args
255
+ end
256
+ end
257
+ end
258
+ end
@@ -0,0 +1,207 @@
1
+ # Copyright (c) 2026 HotKV Ltd
2
+ # Licensed under the Apache License, Version 2.0. See LICENSE in the project root.
3
+
4
+ module HotKV
5
+ module Commands
6
+ # Stream commands (XADD, consumer groups, claiming, trimming, XINFO).
7
+ module Streams
8
+ # +fields+ is a Hash or a flat [field, value, ...] list.
9
+ #
10
+ # Idempotent append: :idmp is a [producer, iid] pair, and the
11
+ # same pair within the stream's IDMP-DURATION adds nothing and returns
12
+ # the original entry id; :idmpauto is a producer name whose id is derived
13
+ # from the fields. Both need id "*".
14
+ def xadd(key, fields, id: "*", maxlen: nil, minid: nil, approx: true, nomkstream: false, limit: nil,
15
+ idmp: nil, idmpauto: nil)
16
+ args = ["XADD", key]
17
+ args.push("NOMKSTREAM") if nomkstream
18
+ args.push("IDMP", idmp[0], idmp[1]) if idmp
19
+ args.push("IDMPAUTO", idmpauto) if idmpauto
20
+ args.concat(trim_clause(maxlen, minid, approx, limit))
21
+ args.push(id)
22
+ args.concat(fields.is_a?(Hash) ? fields.flatten(1) : fields.flatten)
23
+ invoke(*args)
24
+ end
25
+
26
+ # -> Array of [id, {field => value}] entries, oldest first.
27
+ def xrange(key, start = "-", stop = "+", count: nil)
28
+ args = ["XRANGE", key, start, stop]
29
+ args.push("COUNT", count) if count
30
+ invoke(*args) { |r| entries_to_pairs(r) }
31
+ end
32
+
33
+ def xrevrange(key, stop = "+", start = "-", count: nil)
34
+ args = ["XREVRANGE", key, stop, start]
35
+ args.push("COUNT", count) if count
36
+ invoke(*args) { |r| entries_to_pairs(r) }
37
+ end
38
+
39
+ def xlen(key)
40
+ invoke("XLEN", key)
41
+ end
42
+
43
+ def xdel(key, *ids)
44
+ invoke("XDEL", key, *ids.flatten)
45
+ end
46
+
47
+ def xtrim(key, maxlen: nil, minid: nil, approx: true, limit: nil)
48
+ invoke("XTRIM", key, *trim_clause(maxlen, minid, approx, limit))
49
+ end
50
+
51
+ # +keys+/+ids+ are parallel Arrays. -> Hash {stream_key => entries},
52
+ # entries shaped like #xrange's result, or nil on timeout/no data.
53
+ def xread(keys:, ids:, count: nil, block: nil)
54
+ args = ["XREAD"]
55
+ args.push("COUNT", count) if count
56
+ args.push("BLOCK", block) if block
57
+ args.push("STREAMS", *keys, *ids)
58
+ invoke(*args) { |r| streams_to_h(r) }
59
+ end
60
+
61
+ def xreadgroup(group, consumer, keys:, ids:, count: nil, block: nil, noack: false)
62
+ args = ["XREADGROUP", "GROUP", group, consumer]
63
+ args.push("COUNT", count) if count
64
+ args.push("BLOCK", block) if block
65
+ args.push("NOACK") if noack
66
+ args.push("STREAMS", *keys, *ids)
67
+ invoke(*args) { |r| streams_to_h(r) }
68
+ end
69
+
70
+ def xack(key, group, *ids)
71
+ invoke("XACK", key, group, *ids.flatten)
72
+ end
73
+
74
+ def xgroup_create(key, group, id = "$", mkstream: false)
75
+ args = ["XGROUP", "CREATE", key, group, id]
76
+ args.push("MKSTREAM") if mkstream
77
+ invoke(*args) { |r| r == "OK" }
78
+ end
79
+
80
+ def xgroup_destroy(key, group)
81
+ invoke("XGROUP", "DESTROY", key, group) { |r| r == 1 }
82
+ end
83
+
84
+ def xgroup_createconsumer(key, group, consumer)
85
+ invoke("XGROUP", "CREATECONSUMER", key, group, consumer) { |r| r == 1 }
86
+ end
87
+
88
+ def xgroup_delconsumer(key, group, consumer)
89
+ invoke("XGROUP", "DELCONSUMER", key, group, consumer)
90
+ end
91
+
92
+ def xgroup_setid(key, group, id = "$")
93
+ invoke("XGROUP", "SETID", key, group, id) { |r| r == "OK" }
94
+ end
95
+
96
+ # No :start/:stop/:count -> summary Hash {count:, min_id:, max_id:,
97
+ # consumers: [[name, pending_count], ...]}. With them -> an Array of
98
+ # {id:, consumer:, idle_ms:, deliveries:} Hashes (the extended form).
99
+ def xpending(key, group, start: nil, stop: nil, count: nil, consumer: nil, idle: nil)
100
+ if start.nil? && stop.nil? && count.nil?
101
+ invoke("XPENDING", key, group) { |r| pending_summary(r) }
102
+ else
103
+ args = ["XPENDING", key, group]
104
+ args.push("IDLE", idle) if idle
105
+ args.push(start || "-", stop || "+", count || 10)
106
+ args.push(consumer) if consumer
107
+ invoke(*args) { |r| pending_detail(r) }
108
+ end
109
+ end
110
+
111
+ def xclaim(key, group, consumer, min_idle_time, *ids, idle: nil, time: nil, retrycount: nil, force: false,
112
+ justid: false)
113
+ args = ["XCLAIM", key, group, consumer, min_idle_time, *ids.flatten]
114
+ args.push("IDLE", idle) if idle
115
+ args.push("TIME", time) if time
116
+ args.push("RETRYCOUNT", retrycount) if retrycount
117
+ args.push("FORCE") if force
118
+ args.push("JUSTID") if justid
119
+ invoke(*args) { |r| justid ? r : entries_to_pairs(r) }
120
+ end
121
+
122
+ # -> {cursor:, entries:, deleted:}. +entries+ is shaped like #xrange's
123
+ # result unless :justid, in which case it is a plain Array of ids.
124
+ def xautoclaim(key, group, consumer, min_idle_time, start = "0", count: nil, justid: false)
125
+ args = ["XAUTOCLAIM", key, group, consumer, min_idle_time, start]
126
+ args.push("COUNT", count) if count
127
+ args.push("JUSTID") if justid
128
+ invoke(*args) do |r|
129
+ cursor, claimed, deleted = r
130
+ { cursor: cursor, entries: justid ? claimed : entries_to_pairs(claimed), deleted: deleted || [] }
131
+ end
132
+ end
133
+
134
+ def xinfo_stream(key)
135
+ invoke("XINFO", "STREAM", key) { |r| to_h_any(r) }
136
+ end
137
+
138
+ def xinfo_groups(key)
139
+ invoke("XINFO", "GROUPS", key) { |r| r.map { |g| to_h_any(g) } }
140
+ end
141
+
142
+ def xinfo_consumers(key, group)
143
+ invoke("XINFO", "CONSUMERS", key, group) { |r| r.map { |c| to_h_any(c) } }
144
+ end
145
+
146
+ # Sets the last delivered id of +key+ (and optionally the entries added /
147
+ # max deleted id counters).
148
+ def xsetid(key, last_id, entriesadded: nil, maxdeletedid: nil)
149
+ args = ["XSETID", key, last_id]
150
+ args.push("ENTRIESADDED", entriesadded) if entriesadded
151
+ args.push("MAXDELETEDID", maxdeletedid) if maxdeletedid
152
+ invoke(*args) { |r| r == "OK" }
153
+ end
154
+
155
+ # Sets how long (:idmp_duration seconds) and how many (:idmp_maxsize)
156
+ # idempotent ids a stream keeps per producer (XCFGSET).
157
+ def xcfgset(key, idmp_duration: nil, idmp_maxsize: nil)
158
+ args = ["XCFGSET", key]
159
+ args.push("IDMP-DURATION", idmp_duration) if idmp_duration
160
+ args.push("IDMP-MAXSIZE", idmp_maxsize) if idmp_maxsize
161
+ invoke(*args) { |r| r == "OK" }
162
+ end
163
+
164
+ private
165
+
166
+ def trim_clause(maxlen, minid, approx, limit)
167
+ args = []
168
+ if maxlen
169
+ args.push("MAXLEN", approx ? "~" : "=", maxlen)
170
+ elsif minid
171
+ args.push("MINID", approx ? "~" : "=", minid)
172
+ end
173
+ args.push("LIMIT", limit) if limit && !args.empty?
174
+ args
175
+ end
176
+
177
+ def entries_to_pairs(entries)
178
+ return [] if entries.nil?
179
+
180
+ entries.map { |(id, flat)| [id, flat.nil? ? nil : to_h_any(flat)] }
181
+ end
182
+
183
+ def streams_to_h(reply)
184
+ return nil if reply.nil?
185
+
186
+ pairs = reply.is_a?(Hash) ? reply.to_a : reply
187
+ pairs.to_h { |(name, entries)| [name, entries_to_pairs(entries)] }
188
+ end
189
+
190
+ def to_h_any(r)
191
+ r.is_a?(Hash) ? r : r.each_slice(2).to_h
192
+ end
193
+
194
+ def pending_summary(r)
195
+ return { count: 0, min_id: nil, max_id: nil, consumers: [] } if r.nil? || r[0] == 0
196
+
197
+ count, min_id, max_id, consumers = r
198
+ { count: count, min_id: min_id, max_id: max_id,
199
+ consumers: (consumers || []).map { |(name, pending)| [name, pending.to_i] } }
200
+ end
201
+
202
+ def pending_detail(r)
203
+ (r || []).map { |(id, consumer, idle_ms, deliveries)| { id: id, consumer: consumer, idle_ms: idle_ms, deliveries: deliveries } }
204
+ end
205
+ end
206
+ end
207
+ end
@@ -0,0 +1,178 @@
1
+ # Copyright (c) 2026 HotKV Ltd
2
+ # Licensed under the Apache License, Version 2.0. See LICENSE in the project root.
3
+
4
+ module HotKV
5
+ module Commands
6
+ # String commands: GET/SET and friends.
7
+ module Strings
8
+ # Fetches the value of +key+, or nil when it does not exist.
9
+ def get(key)
10
+ invoke("GET", key)
11
+ end
12
+
13
+ # Sets +key+ to +value+. Options mirror the SET command: :ex/:px
14
+ # (relative TTL, seconds/milliseconds), :exat/:pxat (absolute TTL,
15
+ # unix seconds/milliseconds), :keepttl (keep the current TTL),
16
+ # :nx/:xx (only set if absent/present), :get (return the previous
17
+ # value instead of "OK"). :ifeq/:ifne compare the current string value
18
+ # and :ifdeq/:ifdne its digest (see #digest); the write is skipped when
19
+ # the condition fails. Returns "OK", the previous value (with :get), or
20
+ # nil when :nx/:xx/:if* prevented the write.
21
+ def set(key, value, ex: nil, px: nil, exat: nil, pxat: nil, keepttl: false, nx: false, xx: false, get: false,
22
+ ifeq: nil, ifne: nil, ifdeq: nil, ifdne: nil)
23
+ args = ["SET", key, value]
24
+ args.push("EX", ex) if ex
25
+ args.push("PX", px) if px
26
+ args.push("EXAT", exat) if exat
27
+ args.push("PXAT", pxat) if pxat
28
+ args.push("KEEPTTL") if keepttl
29
+ args.push("NX") if nx
30
+ args.push("XX") if xx
31
+ args.push("IFEQ", ifeq) unless ifeq.nil?
32
+ args.push("IFNE", ifne) unless ifne.nil?
33
+ args.push("IFDEQ", ifdeq) unless ifdeq.nil?
34
+ args.push("IFDNE", ifdne) unless ifdne.nil?
35
+ args.push("GET") if get
36
+ invoke(*args)
37
+ end
38
+
39
+ def setnx(key, value)
40
+ invoke("SETNX", key, value) { |r| r == 1 }
41
+ end
42
+
43
+ def setex(key, seconds, value)
44
+ invoke("SETEX", key, seconds, value)
45
+ end
46
+
47
+ def psetex(key, milliseconds, value)
48
+ invoke("PSETEX", key, milliseconds, value)
49
+ end
50
+
51
+ def getset(key, value)
52
+ invoke("GETSET", key, value)
53
+ end
54
+
55
+ def getdel(key)
56
+ invoke("GETDEL", key)
57
+ end
58
+
59
+ def getex(key, ex: nil, px: nil, exat: nil, pxat: nil, persist: false)
60
+ args = ["GETEX", key]
61
+ args.push("EX", ex) if ex
62
+ args.push("PX", px) if px
63
+ args.push("EXAT", exat) if exat
64
+ args.push("PXAT", pxat) if pxat
65
+ args.push("PERSIST") if persist
66
+ invoke(*args)
67
+ end
68
+
69
+ def append(key, value)
70
+ invoke("APPEND", key, value)
71
+ end
72
+
73
+ def strlen(key)
74
+ invoke("STRLEN", key)
75
+ end
76
+
77
+ def incr(key)
78
+ invoke("INCR", key)
79
+ end
80
+
81
+ def decr(key)
82
+ invoke("DECR", key)
83
+ end
84
+
85
+ def incrby(key, amount)
86
+ invoke("INCRBY", key, amount)
87
+ end
88
+
89
+ def decrby(key, amount)
90
+ invoke("DECRBY", key, amount)
91
+ end
92
+
93
+ def incrbyfloat(key, amount)
94
+ invoke("INCRBYFLOAT", key, Wire.finite(amount)) { |r| Wire.parse_float(r) }
95
+ end
96
+
97
+ def mget(*keys)
98
+ invoke("MGET", *keys.flatten)
99
+ end
100
+
101
+ # Accepts either a Hash or an already flat [key, value, key, value...]
102
+ # list.
103
+ def mset(hash_or_pairs)
104
+ invoke("MSET", *flatten_pairs(hash_or_pairs)) { |r| r == "OK" }
105
+ end
106
+
107
+ def msetnx(hash_or_pairs)
108
+ invoke("MSETNX", *flatten_pairs(hash_or_pairs)) { |r| r == 1 }
109
+ end
110
+
111
+ def getrange(key, start, stop)
112
+ invoke("GETRANGE", key, start, stop)
113
+ end
114
+
115
+ def setrange(key, offset, value)
116
+ invoke("SETRANGE", key, offset, value)
117
+ end
118
+
119
+ # Deprecated alias of #getrange.
120
+ def substr(key, start, stop)
121
+ invoke("SUBSTR", key, start, stop)
122
+ end
123
+
124
+ # Longest common subsequence of two string keys. Without options returns
125
+ # the subsequence; :len returns its length; :idx returns a Hash
126
+ # {"matches" => [[[a_start, a_end], [b_start, b_end], (len)], ...],
127
+ # "len" => n} (the match positions, with :withmatchlen the lengths;
128
+ # :minmatchlen drops shorter matches).
129
+ def lcs(key1, key2, len: false, idx: false, minmatchlen: nil, withmatchlen: false)
130
+ args = ["LCS", key1, key2]
131
+ args.push("LEN") if len
132
+ args.push("IDX") if idx
133
+ args.push("MINMATCHLEN", minmatchlen) if minmatchlen
134
+ args.push("WITHMATCHLEN") if withmatchlen
135
+ invoke(*args) { |r| idx && r.is_a?(Array) ? r.each_slice(2).to_h : r }
136
+ end
137
+
138
+ # Sets several keys in one step with one TTL (MSETEX). +pairs+
139
+ # is a Hash or a flat [key, value, ...] list. Options: :nx/:xx (all or
140
+ # none), and one of :ex/:px/:exat/:pxat/:keepttl. Returns true when the
141
+ # keys were set, false when :nx/:xx prevented it.
142
+ def msetex(pairs, nx: false, xx: false, ex: nil, px: nil, exat: nil, pxat: nil, keepttl: false)
143
+ flat = flatten_pairs(pairs)
144
+ args = ["MSETEX", flat.length / 2, *flat]
145
+ args.push("NX") if nx
146
+ args.push("XX") if xx
147
+ args.push("EX", ex) if ex
148
+ args.push("PX", px) if px
149
+ args.push("EXAT", exat) if exat
150
+ args.push("PXAT", pxat) if pxat
151
+ args.push("KEEPTTL") if keepttl
152
+ invoke(*args) { |r| r == 1 }
153
+ end
154
+
155
+ # Deletes +key+, or only when its string value meets a condition
156
+ # (:ifeq/:ifne value, :ifdeq/:ifdne digest). Returns true when deleted.
157
+ def delex(key, ifeq: nil, ifne: nil, ifdeq: nil, ifdne: nil)
158
+ args = ["DELEX", key]
159
+ args.push("IFEQ", ifeq) unless ifeq.nil?
160
+ args.push("IFNE", ifne) unless ifne.nil?
161
+ args.push("IFDEQ", ifdeq) unless ifdeq.nil?
162
+ args.push("IFDNE", ifdne) unless ifdne.nil?
163
+ invoke(*args) { |r| r == 1 }
164
+ end
165
+
166
+ # The XXH3-64 digest of the string at +key+ as 16 hex digits, or nil.
167
+ def digest(key)
168
+ invoke("DIGEST", key)
169
+ end
170
+
171
+ private
172
+
173
+ def flatten_pairs(hash_or_pairs)
174
+ hash_or_pairs.is_a?(Hash) ? hash_or_pairs.flatten(1) : hash_or_pairs.flatten(1)
175
+ end
176
+ end
177
+ end
178
+ end