familia 2.0.0 → 2.1.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.
- checksums.yaml +4 -4
- data/CHANGELOG.rst +45 -0
- data/Gemfile +2 -0
- data/Gemfile.lock +11 -1
- data/docs/guides/writing-migrations.md +345 -0
- data/examples/migrations/v1_to_v2_serialization_migration.rb +374 -0
- data/examples/schemas/customer.json +33 -0
- data/examples/schemas/session.json +27 -0
- data/familia.gemspec +2 -0
- data/lib/familia/data_type/types/hashkey.rb +0 -238
- data/lib/familia/data_type/types/listkey.rb +4 -110
- data/lib/familia/data_type/types/sorted_set.rb +0 -365
- data/lib/familia/data_type/types/stringkey.rb +0 -139
- data/lib/familia/data_type/types/unsorted_set.rb +2 -122
- data/lib/familia/features/schema_validation.rb +139 -0
- data/lib/familia/migration/base.rb +447 -0
- data/lib/familia/migration/errors.rb +31 -0
- data/lib/familia/migration/model.rb +418 -0
- data/lib/familia/migration/pipeline.rb +226 -0
- data/lib/familia/migration/rake_tasks.rake +3 -0
- data/lib/familia/migration/rake_tasks.rb +160 -0
- data/lib/familia/migration/registry.rb +364 -0
- data/lib/familia/migration/runner.rb +311 -0
- data/lib/familia/migration/script.rb +234 -0
- data/lib/familia/migration.rb +43 -0
- data/lib/familia/schema_registry.rb +173 -0
- data/lib/familia/settings.rb +63 -1
- data/lib/familia/version.rb +1 -1
- data/lib/familia.rb +1 -0
- data/try/features/schema_registry_try.rb +193 -0
- data/try/features/schema_validation_feature_try.rb +218 -0
- data/try/migration/base_try.rb +226 -0
- data/try/migration/errors_try.rb +67 -0
- data/try/migration/integration_try.rb +451 -0
- data/try/migration/model_try.rb +431 -0
- data/try/migration/pipeline_try.rb +460 -0
- data/try/migration/rake_tasks_try.rb +61 -0
- data/try/migration/registry_try.rb +199 -0
- data/try/migration/runner_try.rb +311 -0
- data/try/migration/schema_validation_try.rb +201 -0
- data/try/migration/script_try.rb +192 -0
- data/try/migration/v1_to_v2_serialization_try.rb +513 -0
- data/try/performance/benchmarks_try.rb +11 -12
- metadata +44 -1
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
# examples/migrations/v1_to_v2_serialization_migration.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
# V1 to V2 Serialization Migration
|
|
6
|
+
#
|
|
7
|
+
# This migration demonstrates how to upgrade Familia Horreum objects from
|
|
8
|
+
# v1.x serialization format (where values were stored as plain strings via
|
|
9
|
+
# distinguisher logic) to v2.0 format (where ALL values are JSON-encoded
|
|
10
|
+
# for type preservation).
|
|
11
|
+
#
|
|
12
|
+
# == Background
|
|
13
|
+
#
|
|
14
|
+
# In Familia v1.x, serialization was selective:
|
|
15
|
+
# - Simple types (String, Integer, Float, Symbol) → stored as plain strings via `to_s`
|
|
16
|
+
# - Booleans → stored as "true"/"false" strings (type info lost)
|
|
17
|
+
# - nil → stored as "" (empty string) or field not present
|
|
18
|
+
# - Hash/Array → JSON encoded
|
|
19
|
+
#
|
|
20
|
+
# In Familia v2.0, serialization is universal:
|
|
21
|
+
# - ALL values → JSON encoded for type preservation
|
|
22
|
+
# - Strings: "hello" → "\"hello\"" (JSON string with quotes)
|
|
23
|
+
# - Integers: 42 → "42" (JSON number, decoded as Integer)
|
|
24
|
+
# - Booleans: true → "true" (JSON boolean, decoded as TrueClass)
|
|
25
|
+
# - nil → "null" (JSON null, decoded as nil)
|
|
26
|
+
#
|
|
27
|
+
# == Migration Strategy
|
|
28
|
+
#
|
|
29
|
+
# This migration:
|
|
30
|
+
# 1. Scans all Horreum object keys for the specified model
|
|
31
|
+
# 2. Reads raw Redis values (bypassing Familia's deserializer)
|
|
32
|
+
# 3. Detects v1.x format values using heuristics
|
|
33
|
+
# 4. Re-serializes values using v2.0 JSON encoding
|
|
34
|
+
# 5. Writes updated values back to Redis
|
|
35
|
+
#
|
|
36
|
+
# == Usage
|
|
37
|
+
#
|
|
38
|
+
# # Create a subclass for your specific model:
|
|
39
|
+
# class CustomerSerializationMigration < V1ToV2SerializationMigration
|
|
40
|
+
# self.migration_id = '20260201_120000_customer_serialization'
|
|
41
|
+
# self.description = 'Migrate Customer model from v1.x to v2.0 serialization'
|
|
42
|
+
#
|
|
43
|
+
# def prepare
|
|
44
|
+
# @model_class = Customer
|
|
45
|
+
# @batch_size = 100
|
|
46
|
+
# super # Important: calls V1ToV2SerializationMigration's prepare
|
|
47
|
+
# end
|
|
48
|
+
# end
|
|
49
|
+
#
|
|
50
|
+
# # Run dry-run first:
|
|
51
|
+
# CustomerSerializationMigration.cli_run
|
|
52
|
+
#
|
|
53
|
+
# # Run actual migration:
|
|
54
|
+
# CustomerSerializationMigration.cli_run(['--run'])
|
|
55
|
+
#
|
|
56
|
+
# == Field Type Declarations
|
|
57
|
+
#
|
|
58
|
+
# For accurate type detection and conversion, override `field_types_for_model`:
|
|
59
|
+
#
|
|
60
|
+
# def field_types_for_model
|
|
61
|
+
# {
|
|
62
|
+
# email: :string,
|
|
63
|
+
# name: :string,
|
|
64
|
+
# age: :integer,
|
|
65
|
+
# balance: :float,
|
|
66
|
+
# active: :boolean,
|
|
67
|
+
# settings: :hash,
|
|
68
|
+
# tags: :array,
|
|
69
|
+
# deleted_at: :timestamp # Integer timestamp
|
|
70
|
+
# }
|
|
71
|
+
# end
|
|
72
|
+
#
|
|
73
|
+
# This helps the migration correctly interpret v1.x values like:
|
|
74
|
+
# - "true" as boolean (not string)
|
|
75
|
+
# - "42" as integer (not string)
|
|
76
|
+
# - "{}" as hash (already JSON, no change needed)
|
|
77
|
+
#
|
|
78
|
+
require_relative '../../lib/familia'
|
|
79
|
+
require_relative '../../lib/familia/migration'
|
|
80
|
+
|
|
81
|
+
class V1ToV2SerializationMigration < Familia::Migration::Model
|
|
82
|
+
self.migration_id = '20260201_000000_v1_to_v2_serialization_base'
|
|
83
|
+
self.description = 'Base migration for v1.x to v2.0 serialization format'
|
|
84
|
+
|
|
85
|
+
# Type mapping for v1.x → v2.0 conversions
|
|
86
|
+
SUPPORTED_TYPES = %i[string integer float boolean hash array timestamp].freeze
|
|
87
|
+
|
|
88
|
+
def prepare
|
|
89
|
+
raise NotImplementedError, "Subclass must set @model_class in #prepare" unless @model_class
|
|
90
|
+
|
|
91
|
+
@batch_size ||= 100
|
|
92
|
+
@field_types = field_types_for_model
|
|
93
|
+
|
|
94
|
+
info "Migrating #{@model_class.name} with field types: #{@field_types.keys.join(', ')}"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Override in subclass to specify field types for your model
|
|
98
|
+
#
|
|
99
|
+
# @return [Hash<Symbol, Symbol>] field_name => type mapping
|
|
100
|
+
# Supported types: :string, :integer, :float, :boolean, :hash, :array, :timestamp
|
|
101
|
+
def field_types_for_model
|
|
102
|
+
# Default: treat all fields as strings (safest, no-op for most)
|
|
103
|
+
# Override in subclass for type-aware conversion
|
|
104
|
+
{}
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Override load_from_key to skip Familia's deserialization.
|
|
108
|
+
# For v1→v2 migration, we work directly with raw Redis data.
|
|
109
|
+
# The 'obj' returned is actually just the key itself (a String).
|
|
110
|
+
def load_from_key(key)
|
|
111
|
+
key # Return the key directly, we'll read raw values in process_record
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def process_record(dbkey, _original_key)
|
|
115
|
+
# Note: dbkey is the key string (not an object) because we override load_from_key
|
|
116
|
+
# Read raw Redis values (bypass Familia deserialization)
|
|
117
|
+
raw_values = read_raw_values(dbkey)
|
|
118
|
+
|
|
119
|
+
return track_stat(:empty_records) if raw_values.empty?
|
|
120
|
+
|
|
121
|
+
# Detect and convert v1.x values to v2.0 format
|
|
122
|
+
converted = convert_v1_to_v2(raw_values)
|
|
123
|
+
|
|
124
|
+
if converted.empty?
|
|
125
|
+
debug "No fields need conversion for #{dbkey}"
|
|
126
|
+
track_stat(:already_v2_format)
|
|
127
|
+
return
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
debug "Converting #{converted.size} fields for #{dbkey}: #{converted.keys.join(', ')}"
|
|
131
|
+
|
|
132
|
+
for_realsies_this_time? do
|
|
133
|
+
write_converted_values(dbkey, converted)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
track_stat(:records_updated)
|
|
137
|
+
track_stat(:fields_converted, converted.size)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
protected
|
|
141
|
+
|
|
142
|
+
# Read raw string values from Redis, bypassing Familia's deserializer
|
|
143
|
+
def read_raw_values(dbkey)
|
|
144
|
+
redis.hgetall(dbkey)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Write converted values back to Redis using HMSET
|
|
148
|
+
def write_converted_values(dbkey, converted)
|
|
149
|
+
redis.hmset(dbkey, *converted.flatten) if converted.any?
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Convert v1.x format values to v2.0 JSON-encoded format
|
|
153
|
+
#
|
|
154
|
+
# @param raw_values [Hash<String, String>] field_name => raw Redis value
|
|
155
|
+
# @return [Hash<String, String>] field_name => converted JSON value
|
|
156
|
+
def convert_v1_to_v2(raw_values)
|
|
157
|
+
converted = {}
|
|
158
|
+
|
|
159
|
+
raw_values.each do |field_name, raw_value|
|
|
160
|
+
field_sym = field_name.to_sym
|
|
161
|
+
field_type = @field_types[field_sym] || detect_type(raw_value)
|
|
162
|
+
|
|
163
|
+
# Skip if already in v2.0 format
|
|
164
|
+
next if already_v2_format?(raw_value, field_type)
|
|
165
|
+
|
|
166
|
+
# Convert v1.x value to v2.0 format
|
|
167
|
+
v2_value = convert_value(raw_value, field_type)
|
|
168
|
+
|
|
169
|
+
if v2_value != raw_value
|
|
170
|
+
converted[field_name] = v2_value
|
|
171
|
+
track_stat("converted_#{field_type}".to_sym)
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
converted
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Detect if a value is already in v2.0 JSON format
|
|
179
|
+
#
|
|
180
|
+
# v2.0 format characteristics:
|
|
181
|
+
# - Strings are JSON-quoted: "\"hello\""
|
|
182
|
+
# - Numbers, booleans are valid JSON: "42", "true", "false"
|
|
183
|
+
# - null is explicit: "null"
|
|
184
|
+
# - Hashes/Arrays are JSON objects/arrays: "{...}", "[...]"
|
|
185
|
+
#
|
|
186
|
+
# v1.x format characteristics:
|
|
187
|
+
# - Strings are plain: "hello" (no wrapping quotes)
|
|
188
|
+
# - Numbers stored as string but parsed same as JSON
|
|
189
|
+
# - Booleans same as JSON but interpreted as strings
|
|
190
|
+
# - Empty string "" for nil (v2 uses "null")
|
|
191
|
+
def already_v2_format?(value, expected_type)
|
|
192
|
+
# nil values in Ruby don't need conversion (handled elsewhere)
|
|
193
|
+
return true if value.nil?
|
|
194
|
+
|
|
195
|
+
# Empty strings in v1.x represent nil, which should be "null" in v2.0
|
|
196
|
+
# So empty strings are NOT already in v2.0 format
|
|
197
|
+
return false if value.empty?
|
|
198
|
+
|
|
199
|
+
case expected_type
|
|
200
|
+
when :string
|
|
201
|
+
# v2.0 strings start and end with escaped quotes
|
|
202
|
+
value.start_with?('"') && value.end_with?('"')
|
|
203
|
+
|
|
204
|
+
when :integer, :float
|
|
205
|
+
# Numbers look the same in both formats, but v2 JSON parses correctly
|
|
206
|
+
# Can't reliably detect, so we'll skip if parseable as JSON number
|
|
207
|
+
begin
|
|
208
|
+
parsed = Familia::JsonSerializer.parse(value)
|
|
209
|
+
parsed.is_a?(Integer) || parsed.is_a?(Float)
|
|
210
|
+
rescue Familia::SerializerError
|
|
211
|
+
false
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
when :boolean
|
|
215
|
+
# Both formats store "true"/"false", but v1 parses as string
|
|
216
|
+
# v2 parses as actual boolean - can't detect from storage alone
|
|
217
|
+
# We need to re-serialize to ensure correct JSON format
|
|
218
|
+
value == 'true' || value == 'false'
|
|
219
|
+
|
|
220
|
+
when :hash, :array
|
|
221
|
+
# Both v1 and v2 store as JSON, already compatible
|
|
222
|
+
begin
|
|
223
|
+
parsed = Familia::JsonSerializer.parse(value)
|
|
224
|
+
(expected_type == :hash && parsed.is_a?(Hash)) ||
|
|
225
|
+
(expected_type == :array && parsed.is_a?(Array))
|
|
226
|
+
rescue Familia::SerializerError
|
|
227
|
+
false
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
when :timestamp
|
|
231
|
+
# Timestamps are integers, same handling as :integer
|
|
232
|
+
already_v2_format?(value, :integer)
|
|
233
|
+
|
|
234
|
+
else
|
|
235
|
+
false
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Convert a v1.x value to v2.0 JSON-encoded format
|
|
240
|
+
#
|
|
241
|
+
# @param raw_value [String] The raw Redis string value
|
|
242
|
+
# @param field_type [Symbol] Expected field type
|
|
243
|
+
# @return [String] JSON-encoded value for v2.0 storage
|
|
244
|
+
def convert_value(raw_value, field_type)
|
|
245
|
+
# Handle empty string (v1.x nil representation)
|
|
246
|
+
return 'null' if raw_value == ''
|
|
247
|
+
|
|
248
|
+
ruby_value = parse_v1_value(raw_value, field_type)
|
|
249
|
+
Familia::JsonSerializer.dump(ruby_value)
|
|
250
|
+
rescue StandardError => e
|
|
251
|
+
warn "Failed to convert value '#{raw_value}' as #{field_type}: #{e.message}"
|
|
252
|
+
track_stat(:conversion_errors)
|
|
253
|
+
raw_value # Return original on error
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# Parse a v1.x stored value to its Ruby type
|
|
257
|
+
#
|
|
258
|
+
# @param raw_value [String] The raw Redis string value
|
|
259
|
+
# @param field_type [Symbol] Expected field type
|
|
260
|
+
# @return [Object] The parsed Ruby value
|
|
261
|
+
def parse_v1_value(raw_value, field_type)
|
|
262
|
+
case field_type
|
|
263
|
+
when :string
|
|
264
|
+
# v1 strings are stored as-is, already correct Ruby type
|
|
265
|
+
raw_value
|
|
266
|
+
|
|
267
|
+
when :integer, :timestamp
|
|
268
|
+
# v1 integers stored as string "42"
|
|
269
|
+
raw_value.to_i
|
|
270
|
+
|
|
271
|
+
when :float
|
|
272
|
+
# v1 floats stored as string "3.14"
|
|
273
|
+
raw_value.to_f
|
|
274
|
+
|
|
275
|
+
when :boolean
|
|
276
|
+
# v1 booleans stored as "true"/"false" strings
|
|
277
|
+
raw_value == 'true'
|
|
278
|
+
|
|
279
|
+
when :hash, :array
|
|
280
|
+
# v1 complex types already JSON-encoded, parse them
|
|
281
|
+
begin
|
|
282
|
+
Familia::JsonSerializer.parse(raw_value)
|
|
283
|
+
rescue Familia::SerializerError
|
|
284
|
+
# Corrupted JSON, return empty structure
|
|
285
|
+
field_type == :hash ? {} : []
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
else
|
|
289
|
+
# Unknown type, treat as string
|
|
290
|
+
raw_value
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# Attempt to detect the type of a value from its format
|
|
295
|
+
#
|
|
296
|
+
# Used when field_types_for_model doesn't specify a field type.
|
|
297
|
+
# This is a heuristic and may not always be accurate.
|
|
298
|
+
#
|
|
299
|
+
# @param value [String] The raw Redis value
|
|
300
|
+
# @return [Symbol] Detected type (defaults to :string)
|
|
301
|
+
def detect_type(value)
|
|
302
|
+
return :string if value.nil? || value.empty?
|
|
303
|
+
|
|
304
|
+
# JSON object (hash)
|
|
305
|
+
return :hash if value.start_with?('{') && value.end_with?('}')
|
|
306
|
+
|
|
307
|
+
# JSON array
|
|
308
|
+
return :array if value.start_with?('[') && value.end_with?(']')
|
|
309
|
+
|
|
310
|
+
# Potential boolean
|
|
311
|
+
return :boolean if %w[true false].include?(value)
|
|
312
|
+
|
|
313
|
+
# JSON null (v2.0 format for nil)
|
|
314
|
+
return :string if value == 'null'
|
|
315
|
+
|
|
316
|
+
# Potential integer
|
|
317
|
+
return :integer if value.match?(/\A-?\d+\z/)
|
|
318
|
+
|
|
319
|
+
# Potential float
|
|
320
|
+
return :float if value.match?(/\A-?\d+\.\d+\z/)
|
|
321
|
+
|
|
322
|
+
# Already JSON-quoted string (v2.0 format)
|
|
323
|
+
return :string if value.start_with?('"') && value.end_with?('"')
|
|
324
|
+
|
|
325
|
+
# Default: plain string (v1.x format)
|
|
326
|
+
:string
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
# Example: Concrete migration for a User model
|
|
331
|
+
#
|
|
332
|
+
# Uncomment and customize for your application:
|
|
333
|
+
#
|
|
334
|
+
# class UserSerializationMigration < V1ToV2SerializationMigration
|
|
335
|
+
# self.migration_id = '20260201_120000_user_serialization'
|
|
336
|
+
# self.description = 'Migrate User model from v1.x to v2.0 serialization'
|
|
337
|
+
#
|
|
338
|
+
# def prepare
|
|
339
|
+
# @model_class = User
|
|
340
|
+
# @batch_size = 100
|
|
341
|
+
# super
|
|
342
|
+
# end
|
|
343
|
+
#
|
|
344
|
+
# def field_types_for_model
|
|
345
|
+
# {
|
|
346
|
+
# email: :string,
|
|
347
|
+
# name: :string,
|
|
348
|
+
# age: :integer,
|
|
349
|
+
# balance: :float,
|
|
350
|
+
# active: :boolean,
|
|
351
|
+
# verified: :boolean,
|
|
352
|
+
# login_count: :integer,
|
|
353
|
+
# last_login_at: :timestamp,
|
|
354
|
+
# settings: :hash,
|
|
355
|
+
# roles: :array
|
|
356
|
+
# }
|
|
357
|
+
# end
|
|
358
|
+
# end
|
|
359
|
+
|
|
360
|
+
if $PROGRAM_NAME == __FILE__
|
|
361
|
+
puts "V1ToV2SerializationMigration is a base class."
|
|
362
|
+
puts "Create a subclass for your specific model and run that."
|
|
363
|
+
puts
|
|
364
|
+
puts "Example:"
|
|
365
|
+
puts " class CustomerMigration < V1ToV2SerializationMigration"
|
|
366
|
+
puts " self.migration_id = '20260201_customer_v2'"
|
|
367
|
+
puts " def prepare"
|
|
368
|
+
puts " @model_class = Customer"
|
|
369
|
+
puts " super"
|
|
370
|
+
puts " end"
|
|
371
|
+
puts " end"
|
|
372
|
+
puts
|
|
373
|
+
puts " CustomerMigration.cli_run"
|
|
374
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://example.com/schemas/customer.json",
|
|
4
|
+
"title": "Customer",
|
|
5
|
+
"description": "Customer model schema for Familia ORM",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"properties": {
|
|
8
|
+
"custid": {
|
|
9
|
+
"type": "string",
|
|
10
|
+
"description": "Unique customer identifier"
|
|
11
|
+
},
|
|
12
|
+
"email": {
|
|
13
|
+
"type": "string",
|
|
14
|
+
"format": "email",
|
|
15
|
+
"description": "Customer email address"
|
|
16
|
+
},
|
|
17
|
+
"name": {
|
|
18
|
+
"type": "string",
|
|
19
|
+
"minLength": 1,
|
|
20
|
+
"maxLength": 255
|
|
21
|
+
},
|
|
22
|
+
"status": {
|
|
23
|
+
"type": "string",
|
|
24
|
+
"enum": ["active", "inactive", "pending"],
|
|
25
|
+
"default": "pending"
|
|
26
|
+
},
|
|
27
|
+
"created_at": {
|
|
28
|
+
"type": "number",
|
|
29
|
+
"description": "Unix timestamp"
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"required": ["custid", "email"]
|
|
33
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://example.com/schemas/session.json",
|
|
4
|
+
"title": "Session",
|
|
5
|
+
"description": "User session model schema",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"properties": {
|
|
8
|
+
"sessid": {
|
|
9
|
+
"type": "string",
|
|
10
|
+
"pattern": "^[a-f0-9]{32}$",
|
|
11
|
+
"description": "32-character hex session ID"
|
|
12
|
+
},
|
|
13
|
+
"customer_id": {
|
|
14
|
+
"type": "string"
|
|
15
|
+
},
|
|
16
|
+
"expires_at": {
|
|
17
|
+
"type": "number",
|
|
18
|
+
"description": "Unix timestamp when session expires"
|
|
19
|
+
},
|
|
20
|
+
"data": {
|
|
21
|
+
"type": "object",
|
|
22
|
+
"additionalProperties": true,
|
|
23
|
+
"description": "Arbitrary session data"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"required": ["sessid"]
|
|
27
|
+
}
|
data/familia.gemspec
CHANGED
|
@@ -129,244 +129,6 @@ module Familia
|
|
|
129
129
|
deserialize_values(*elements)
|
|
130
130
|
end
|
|
131
131
|
|
|
132
|
-
# Incrementally iterates over fields in the hash using cursor-based iteration.
|
|
133
|
-
# This is more memory-efficient than `hgetall` for large hashes.
|
|
134
|
-
#
|
|
135
|
-
# @param cursor [Integer] The cursor position to start from (0 for initial call)
|
|
136
|
-
# @param match [String, nil] Optional glob-style pattern to filter field names
|
|
137
|
-
# @param count [Integer, nil] Optional hint for number of elements to return per call
|
|
138
|
-
# @return [Array<String, Hash>] A two-element array: [new_cursor, {field => value, ...}]
|
|
139
|
-
# When new_cursor is "0", iteration is complete.
|
|
140
|
-
#
|
|
141
|
-
# @example Basic iteration
|
|
142
|
-
# cursor = 0
|
|
143
|
-
# loop do
|
|
144
|
-
# cursor, results = my_hash.scan(cursor)
|
|
145
|
-
# results.each { |field, value| puts "#{field}: #{value}" }
|
|
146
|
-
# break if cursor == "0"
|
|
147
|
-
# end
|
|
148
|
-
#
|
|
149
|
-
# @example With pattern matching
|
|
150
|
-
# cursor, results = my_hash.scan(0, match: "user:*", count: 100)
|
|
151
|
-
def scan(cursor = 0, match: nil, count: nil)
|
|
152
|
-
args = [dbkey, cursor]
|
|
153
|
-
args += ['MATCH', match] if match
|
|
154
|
-
args += ['COUNT', count] if count
|
|
155
|
-
|
|
156
|
-
new_cursor, pairs = dbclient.hscan(*args)
|
|
157
|
-
|
|
158
|
-
# pairs is an array of [field, value] pairs, convert to hash with deserialization
|
|
159
|
-
result_hash = pairs.to_h.transform_values { |v| deserialize_value(v) }
|
|
160
|
-
|
|
161
|
-
[new_cursor, result_hash]
|
|
162
|
-
end
|
|
163
|
-
alias hscan scan
|
|
164
|
-
|
|
165
|
-
# Increments the float value of a hash field by the given amount.
|
|
166
|
-
#
|
|
167
|
-
# @param field [String] The field name
|
|
168
|
-
# @param by [Float, Integer] The amount to increment by (can be negative)
|
|
169
|
-
# @return [Float] The new value after incrementing
|
|
170
|
-
#
|
|
171
|
-
# @example
|
|
172
|
-
# my_hash.incrbyfloat('temperature', 0.5) #=> 23.5
|
|
173
|
-
# my_hash.incrbyfloat('temperature', -1.2) #=> 22.3
|
|
174
|
-
def incrbyfloat(field, by)
|
|
175
|
-
dbclient.hincrbyfloat(dbkey, field.to_s, by).to_f
|
|
176
|
-
end
|
|
177
|
-
alias incrfloat incrbyfloat
|
|
178
|
-
|
|
179
|
-
# Returns the string length of the value associated with field.
|
|
180
|
-
#
|
|
181
|
-
# @param field [String] The field name
|
|
182
|
-
# @return [Integer] The length of the value in bytes, or 0 if field does not exist
|
|
183
|
-
#
|
|
184
|
-
# @example
|
|
185
|
-
# my_hash['name'] = 'Alice'
|
|
186
|
-
# my_hash.strlen('name') #=> 7 (includes JSON quotes: "Alice")
|
|
187
|
-
def strlen(field)
|
|
188
|
-
dbclient.hstrlen(dbkey, field.to_s)
|
|
189
|
-
end
|
|
190
|
-
alias hstrlen strlen
|
|
191
|
-
|
|
192
|
-
# Returns one or more random fields from the hash.
|
|
193
|
-
#
|
|
194
|
-
# @param count [Integer, nil] Number of fields to return. If nil, returns a single field.
|
|
195
|
-
# If positive, returns distinct fields. If negative, allows duplicates.
|
|
196
|
-
# @param withvalues [Boolean] If true, returns fields with their values
|
|
197
|
-
# @return [String, Array<String>, Array<Array>] Depending on arguments:
|
|
198
|
-
# - No count: single field name (or nil if hash is empty)
|
|
199
|
-
# - With count: array of field names
|
|
200
|
-
# - With count and withvalues: array of [field, value] pairs
|
|
201
|
-
#
|
|
202
|
-
# @example Get a single random field
|
|
203
|
-
# my_hash.randfield #=> "some_field"
|
|
204
|
-
#
|
|
205
|
-
# @example Get 3 distinct random fields
|
|
206
|
-
# my_hash.randfield(3) #=> ["field1", "field2", "field3"]
|
|
207
|
-
#
|
|
208
|
-
# @example Get 2 random fields with values
|
|
209
|
-
# my_hash.randfield(2, withvalues: true) #=> [["field1", value1], ["field2", value2]]
|
|
210
|
-
def randfield(count = nil, withvalues: false)
|
|
211
|
-
if count.nil?
|
|
212
|
-
dbclient.hrandfield(dbkey)
|
|
213
|
-
elsif withvalues
|
|
214
|
-
pairs = dbclient.hrandfield(dbkey, count, 'WITHVALUES')
|
|
215
|
-
# pairs is array of [field, value, field, value, ...]
|
|
216
|
-
# Convert to array of [field, deserialized_value] pairs
|
|
217
|
-
pairs.each_slice(2).map { |field, val| [field, deserialize_value(val)] }
|
|
218
|
-
else
|
|
219
|
-
dbclient.hrandfield(dbkey, count)
|
|
220
|
-
end
|
|
221
|
-
end
|
|
222
|
-
alias hrandfield randfield
|
|
223
|
-
|
|
224
|
-
# -----------------------------------------------------------------------
|
|
225
|
-
# Field-Level Expiration Methods (Redis 7.4+)
|
|
226
|
-
#
|
|
227
|
-
# These methods require Redis/Valkey 7.4 or later. They allow setting
|
|
228
|
-
# TTL on individual hash fields rather than the entire key.
|
|
229
|
-
# -----------------------------------------------------------------------
|
|
230
|
-
|
|
231
|
-
# Sets expiration time in seconds on one or more hash fields.
|
|
232
|
-
# @note Requires Redis 7.4+
|
|
233
|
-
#
|
|
234
|
-
# @param seconds [Integer] TTL in seconds
|
|
235
|
-
# @param fields [Array<String>] One or more field names
|
|
236
|
-
# @return [Array<Integer>] Array of results for each field:
|
|
237
|
-
# -2 if field does not exist, 1 if expiration was set,
|
|
238
|
-
# 0 if expiration was not set (e.g., field has no expiration)
|
|
239
|
-
#
|
|
240
|
-
# @example Set 1 hour TTL on specific fields
|
|
241
|
-
# my_hash.expire_fields(3600, 'session_token', 'temp_data')
|
|
242
|
-
def expire_fields(seconds, *fields)
|
|
243
|
-
string_fields = fields.flatten.compact.map(&:to_s)
|
|
244
|
-
dbclient.call('HEXPIRE', dbkey, seconds, 'FIELDS', string_fields.size, *string_fields)
|
|
245
|
-
end
|
|
246
|
-
alias hexpire expire_fields
|
|
247
|
-
|
|
248
|
-
# Sets expiration time in milliseconds on one or more hash fields.
|
|
249
|
-
# @note Requires Redis 7.4+
|
|
250
|
-
#
|
|
251
|
-
# @param milliseconds [Integer] TTL in milliseconds
|
|
252
|
-
# @param fields [Array<String>] One or more field names
|
|
253
|
-
# @return [Array<Integer>] Array of results for each field
|
|
254
|
-
#
|
|
255
|
-
# @example Set 500ms TTL on a field
|
|
256
|
-
# my_hash.pexpire_fields(500, 'rate_limit_counter')
|
|
257
|
-
def pexpire_fields(milliseconds, *fields)
|
|
258
|
-
string_fields = fields.flatten.compact.map(&:to_s)
|
|
259
|
-
dbclient.call('HPEXPIRE', dbkey, milliseconds, 'FIELDS', string_fields.size, *string_fields)
|
|
260
|
-
end
|
|
261
|
-
alias hpexpire pexpire_fields
|
|
262
|
-
|
|
263
|
-
# Sets absolute expiration time (Unix timestamp in seconds) on hash fields.
|
|
264
|
-
# @note Requires Redis 7.4+
|
|
265
|
-
#
|
|
266
|
-
# @param unix_time [Integer] Absolute Unix timestamp in seconds
|
|
267
|
-
# @param fields [Array<String>] One or more field names
|
|
268
|
-
# @return [Array<Integer>] Array of results for each field
|
|
269
|
-
#
|
|
270
|
-
# @example Expire fields at midnight tonight
|
|
271
|
-
# midnight = Time.now.to_i + (24 * 60 * 60)
|
|
272
|
-
# my_hash.expireat_fields(midnight, 'daily_counter')
|
|
273
|
-
def expireat_fields(unix_time, *fields)
|
|
274
|
-
string_fields = fields.flatten.compact.map(&:to_s)
|
|
275
|
-
dbclient.call('HEXPIREAT', dbkey, unix_time, 'FIELDS', string_fields.size, *string_fields)
|
|
276
|
-
end
|
|
277
|
-
alias hexpireat expireat_fields
|
|
278
|
-
|
|
279
|
-
# Sets absolute expiration time (Unix timestamp in milliseconds) on hash fields.
|
|
280
|
-
# @note Requires Redis 7.4+
|
|
281
|
-
#
|
|
282
|
-
# @param unix_time_ms [Integer] Absolute Unix timestamp in milliseconds
|
|
283
|
-
# @param fields [Array<String>] One or more field names
|
|
284
|
-
# @return [Array<Integer>] Array of results for each field
|
|
285
|
-
#
|
|
286
|
-
# @example Expire field at a precise millisecond
|
|
287
|
-
# my_hash.pexpireat_fields(1700000000000, 'precise_data')
|
|
288
|
-
def pexpireat_fields(unix_time_ms, *fields)
|
|
289
|
-
string_fields = fields.flatten.compact.map(&:to_s)
|
|
290
|
-
dbclient.call('HPEXPIREAT', dbkey, unix_time_ms, 'FIELDS', string_fields.size, *string_fields)
|
|
291
|
-
end
|
|
292
|
-
alias hpexpireat pexpireat_fields
|
|
293
|
-
|
|
294
|
-
# Returns the remaining TTL in seconds for one or more hash fields.
|
|
295
|
-
# @note Requires Redis 7.4+
|
|
296
|
-
#
|
|
297
|
-
# @param fields [Array<String>] One or more field names
|
|
298
|
-
# @return [Array<Integer>] Array of TTL values for each field:
|
|
299
|
-
# -2 if field does not exist, -1 if field has no expiration,
|
|
300
|
-
# otherwise the TTL in seconds
|
|
301
|
-
#
|
|
302
|
-
# @example Check remaining TTL on fields
|
|
303
|
-
# my_hash.ttl_fields('session_token', 'temp_data') #=> [3600, -1]
|
|
304
|
-
def ttl_fields(*fields)
|
|
305
|
-
string_fields = fields.flatten.compact.map(&:to_s)
|
|
306
|
-
dbclient.call('HTTL', dbkey, 'FIELDS', string_fields.size, *string_fields)
|
|
307
|
-
end
|
|
308
|
-
alias httl ttl_fields
|
|
309
|
-
|
|
310
|
-
# Returns the remaining TTL in milliseconds for one or more hash fields.
|
|
311
|
-
# @note Requires Redis 7.4+
|
|
312
|
-
#
|
|
313
|
-
# @param fields [Array<String>] One or more field names
|
|
314
|
-
# @return [Array<Integer>] Array of TTL values in milliseconds
|
|
315
|
-
#
|
|
316
|
-
# @example Check remaining TTL in milliseconds
|
|
317
|
-
# my_hash.pttl_fields('rate_limit') #=> [450]
|
|
318
|
-
def pttl_fields(*fields)
|
|
319
|
-
string_fields = fields.flatten.compact.map(&:to_s)
|
|
320
|
-
dbclient.call('HPTTL', dbkey, 'FIELDS', string_fields.size, *string_fields)
|
|
321
|
-
end
|
|
322
|
-
alias hpttl pttl_fields
|
|
323
|
-
|
|
324
|
-
# Removes expiration from one or more hash fields.
|
|
325
|
-
# @note Requires Redis 7.4+
|
|
326
|
-
#
|
|
327
|
-
# @param fields [Array<String>] One or more field names
|
|
328
|
-
# @return [Array<Integer>] Array of results for each field:
|
|
329
|
-
# -2 if field does not exist, -1 if field has no expiration,
|
|
330
|
-
# 1 if expiration was removed
|
|
331
|
-
#
|
|
332
|
-
# @example Remove expiration from fields
|
|
333
|
-
# my_hash.persist_fields('important_data') #=> [1]
|
|
334
|
-
def persist_fields(*fields)
|
|
335
|
-
string_fields = fields.flatten.compact.map(&:to_s)
|
|
336
|
-
dbclient.call('HPERSIST', dbkey, 'FIELDS', string_fields.size, *string_fields)
|
|
337
|
-
end
|
|
338
|
-
alias hpersist persist_fields
|
|
339
|
-
|
|
340
|
-
# Returns the absolute Unix expiration timestamp in seconds for hash fields.
|
|
341
|
-
# @note Requires Redis 7.4+
|
|
342
|
-
#
|
|
343
|
-
# @param fields [Array<String>] One or more field names
|
|
344
|
-
# @return [Array<Integer>] Array of timestamps for each field:
|
|
345
|
-
# -2 if field does not exist, -1 if field has no expiration,
|
|
346
|
-
# otherwise the absolute Unix timestamp in seconds
|
|
347
|
-
#
|
|
348
|
-
# @example Get expiration timestamp
|
|
349
|
-
# my_hash.expiretime_fields('session') #=> [1700000000]
|
|
350
|
-
def expiretime_fields(*fields)
|
|
351
|
-
string_fields = fields.flatten.compact.map(&:to_s)
|
|
352
|
-
dbclient.call('HEXPIRETIME', dbkey, 'FIELDS', string_fields.size, *string_fields)
|
|
353
|
-
end
|
|
354
|
-
alias hexpiretime expiretime_fields
|
|
355
|
-
|
|
356
|
-
# Returns the absolute Unix expiration timestamp in milliseconds for hash fields.
|
|
357
|
-
# @note Requires Redis 7.4+
|
|
358
|
-
#
|
|
359
|
-
# @param fields [Array<String>] One or more field names
|
|
360
|
-
# @return [Array<Integer>] Array of timestamps in milliseconds
|
|
361
|
-
#
|
|
362
|
-
# @example Get precise expiration timestamp
|
|
363
|
-
# my_hash.pexpiretime_fields('session') #=> [1700000000000]
|
|
364
|
-
def pexpiretime_fields(*fields)
|
|
365
|
-
string_fields = fields.flatten.compact.map(&:to_s)
|
|
366
|
-
dbclient.call('HPEXPIRETIME', dbkey, 'FIELDS', string_fields.size, *string_fields)
|
|
367
|
-
end
|
|
368
|
-
alias hpexpiretime pexpiretime_fields
|
|
369
|
-
|
|
370
132
|
# The Great Database Refresh-o-matic 3000 for HashKey!
|
|
371
133
|
#
|
|
372
134
|
# This method performs a complete refresh of the hash's state from the database.
|