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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.rst +45 -0
  3. data/Gemfile +2 -0
  4. data/Gemfile.lock +11 -1
  5. data/docs/guides/writing-migrations.md +345 -0
  6. data/examples/migrations/v1_to_v2_serialization_migration.rb +374 -0
  7. data/examples/schemas/customer.json +33 -0
  8. data/examples/schemas/session.json +27 -0
  9. data/familia.gemspec +2 -0
  10. data/lib/familia/data_type/types/hashkey.rb +0 -238
  11. data/lib/familia/data_type/types/listkey.rb +4 -110
  12. data/lib/familia/data_type/types/sorted_set.rb +0 -365
  13. data/lib/familia/data_type/types/stringkey.rb +0 -139
  14. data/lib/familia/data_type/types/unsorted_set.rb +2 -122
  15. data/lib/familia/features/schema_validation.rb +139 -0
  16. data/lib/familia/migration/base.rb +447 -0
  17. data/lib/familia/migration/errors.rb +31 -0
  18. data/lib/familia/migration/model.rb +418 -0
  19. data/lib/familia/migration/pipeline.rb +226 -0
  20. data/lib/familia/migration/rake_tasks.rake +3 -0
  21. data/lib/familia/migration/rake_tasks.rb +160 -0
  22. data/lib/familia/migration/registry.rb +364 -0
  23. data/lib/familia/migration/runner.rb +311 -0
  24. data/lib/familia/migration/script.rb +234 -0
  25. data/lib/familia/migration.rb +43 -0
  26. data/lib/familia/schema_registry.rb +173 -0
  27. data/lib/familia/settings.rb +63 -1
  28. data/lib/familia/version.rb +1 -1
  29. data/lib/familia.rb +1 -0
  30. data/try/features/schema_registry_try.rb +193 -0
  31. data/try/features/schema_validation_feature_try.rb +218 -0
  32. data/try/migration/base_try.rb +226 -0
  33. data/try/migration/errors_try.rb +67 -0
  34. data/try/migration/integration_try.rb +451 -0
  35. data/try/migration/model_try.rb +431 -0
  36. data/try/migration/pipeline_try.rb +460 -0
  37. data/try/migration/rake_tasks_try.rb +61 -0
  38. data/try/migration/registry_try.rb +199 -0
  39. data/try/migration/runner_try.rb +311 -0
  40. data/try/migration/schema_validation_try.rb +201 -0
  41. data/try/migration/script_try.rb +192 -0
  42. data/try/migration/v1_to_v2_serialization_try.rb +513 -0
  43. data/try/performance/benchmarks_try.rb +11 -12
  44. 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
@@ -28,5 +28,7 @@ Gem::Specification.new do |spec|
28
28
  spec.add_dependency 'stringio', '~> 3.1.1'
29
29
  spec.add_dependency 'uri-valkey', '~> 1.4'
30
30
 
31
+ spec.add_development_dependency 'json_schemer', '~> 2.0'
32
+
31
33
  spec.metadata['rubygems_mfa_required'] = 'true'
32
34
  end
@@ -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.