familia 1.0.0.pre.rc7 → 1.2.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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +1 -0
  3. data/.pre-commit-config.yaml +1 -1
  4. data/Gemfile +3 -1
  5. data/Gemfile.lock +3 -1
  6. data/README.md +5 -5
  7. data/VERSION.yml +1 -2
  8. data/familia.gemspec +1 -2
  9. data/lib/familia/base.rb +11 -11
  10. data/lib/familia/connection.rb +18 -2
  11. data/lib/familia/errors.rb +14 -0
  12. data/lib/familia/features/expiration.rb +13 -2
  13. data/lib/familia/features/quantization.rb +1 -1
  14. data/lib/familia/horreum/class_methods.rb +31 -21
  15. data/lib/familia/horreum/commands.rb +58 -15
  16. data/lib/familia/horreum/relations_management.rb +9 -2
  17. data/lib/familia/horreum/serialization.rb +130 -29
  18. data/lib/familia/horreum.rb +69 -19
  19. data/lib/familia/redistype/commands.rb +5 -2
  20. data/lib/familia/redistype/serialization.rb +17 -16
  21. data/lib/familia/redistype/types/hashkey.rb +167 -0
  22. data/lib/familia/{types → redistype/types}/list.rb +19 -14
  23. data/lib/familia/{types → redistype/types}/sorted_set.rb +22 -19
  24. data/lib/familia/{types → redistype/types}/string.rb +8 -6
  25. data/lib/familia/{types → redistype/types}/unsorted_set.rb +16 -12
  26. data/lib/familia/redistype.rb +19 -9
  27. data/lib/familia.rb +6 -1
  28. data/try/10_familia_try.rb +1 -1
  29. data/try/20_redis_type_try.rb +1 -1
  30. data/try/21_redis_type_zset_try.rb +1 -1
  31. data/try/22_redis_type_set_try.rb +1 -1
  32. data/try/23_redis_type_list_try.rb +2 -2
  33. data/try/24_redis_type_string_try.rb +3 -3
  34. data/try/25_redis_type_hash_try.rb +1 -1
  35. data/try/26_redis_bool_try.rb +2 -2
  36. data/try/27_redis_horreum_try.rb +2 -2
  37. data/try/28_redis_horreum_serialization_try.rb +159 -0
  38. data/try/29_redis_horreum_initialization_try.rb +113 -0
  39. data/try/30_familia_object_try.rb +8 -5
  40. data/try/40_customer_try.rb +6 -6
  41. data/try/91_json_bug_try.rb +86 -0
  42. data/try/92_symbolize_try.rb +96 -0
  43. data/try/93_string_coercion_try.rb +154 -0
  44. metadata +20 -15
  45. data/lib/familia/types/hashkey.rb +0 -108
@@ -97,15 +97,8 @@ module Familia
97
97
  # connection. Don't worry, it puts everything back where it found it when it's done.
98
98
  #
99
99
  def transaction
100
- original_redis = self.redis
101
-
102
- begin
103
- redis.multi do |conn|
104
- self.instance_variable_set(:@redis, conn)
105
- yield(conn)
106
- end
107
- ensure
108
- self.redis = original_redis
100
+ redis.multi do |conn|
101
+ yield(conn)
109
102
  end
110
103
  end
111
104
 
@@ -128,22 +121,58 @@ module Familia
128
121
  def save update_expiration: true
129
122
  Familia.trace :SAVE, redis, redisuri, caller(1..1) if Familia.debug?
130
123
 
131
- # Update our object's life story
132
- self.key ||= self.identifier
133
- self.updated = Familia.now.to_i
134
- self.created ||= Familia.now.to_i
124
+ # Update our object's life story, keeping the mandatory built-in
125
+ # key field in sync with the field that is the chosen identifier.
126
+ self.key = self.identifier
127
+ self.created ||= Familia.now.to_i if respond_to?(:created)
128
+ self.updated = Familia.now.to_i if respond_to?(:updated)
135
129
 
136
130
  # Commit our tale to the Redis chronicles
137
131
  #
138
132
  # e.g. `ret` # => MultiResult.new(true, ["OK", "OK"])
139
133
  ret = commit_fields(update_expiration: update_expiration)
140
134
 
141
- Familia.ld "[save] #{self.class} #{rediskey} #{ret}"
135
+ Familia.ld "[save] #{self.class} #{rediskey} #{ret} (update_expiration: #{update_expiration})"
142
136
 
143
137
  # Did Redis accept our offering?
144
138
  ret.successful?
145
139
  end
146
140
 
141
+ # Updates multiple fields atomically in a Redis transaction.
142
+ #
143
+ # @param fields [Hash] Field names and values to update. Special key :update_expiration
144
+ # controls whether to update key expiration (default: true)
145
+ # @return [MultiResult] Transaction result
146
+ #
147
+ # @example Update multiple fields without affecting expiration
148
+ # metadata.batch_update(viewed: 1, updated: Time.now.to_i, update_expiration: false)
149
+ #
150
+ # @example Update fields with expiration refresh
151
+ # user.batch_update(name: "John", email: "john@example.com")
152
+ #
153
+ def batch_update(**kwargs)
154
+ update_expiration = kwargs.delete(:update_expiration) { true }
155
+ fields = kwargs
156
+
157
+ Familia.trace :BATCH_UPDATE, redis, fields.keys, caller(1..1) if Familia.debug?
158
+
159
+ command_return_values = transaction do |conn|
160
+ fields.each do |field, value|
161
+ prepared_value = serialize_value(value)
162
+ conn.hset rediskey, field, prepared_value
163
+ # Update instance variable to keep object in sync
164
+ send("#{field}=", value) if respond_to?("#{field}=")
165
+ end
166
+ end
167
+
168
+ # Update expiration if requested and supported
169
+ self.update_expiration(ttl: nil) if update_expiration && respond_to?(:update_expiration)
170
+
171
+ # Return same MultiResult format as other methods
172
+ summary_boolean = command_return_values.all? { |ret| %w[OK 0 1].include?(ret.to_s) }
173
+ MultiResult.new(summary_boolean, command_return_values)
174
+ end
175
+
147
176
  # Apply a smattering of fields to this object like fairy dust.
148
177
  #
149
178
  # @param fields [Hash] A magical bag of named attributes to sprinkle onto
@@ -174,6 +203,10 @@ module Familia
174
203
  # It executes a transaction that includes setting field values and,
175
204
  # if applicable, updating the expiration time.
176
205
  #
206
+ # @param update_expiration [Boolean] Whether to update the expiration time
207
+ # of the Redis key. This is true by default, but can be disabled if you
208
+ # don't want to mess with the cosmic balance of your key's lifespan.
209
+ #
177
210
  # @return [MultiResult] A mystical object containing:
178
211
  # - success: A boolean indicating if all Redis commands succeeded
179
212
  # - results: An array of strings, cryptic messages from the Redis gods
@@ -213,13 +246,12 @@ module Familia
213
246
  def commit_fields update_expiration: true
214
247
  Familia.ld "[commit_fields1] #{self.class} #{rediskey} #{to_h} (update_expiration: #{update_expiration})"
215
248
  command_return_values = transaction do |conn|
216
- hmset
217
-
218
- # Only classes that have the expiration ferature enabled will
219
- # actually set an expiration time on their keys. Otherwise
220
- # this will be a no-op that simply logs the attempt.
221
- self.update_expiration if update_expiration
249
+ conn.hmset rediskey(suffix), self.to_h # using the prepared connection
222
250
  end
251
+ # Only classes that have the expiration ferature enabled will
252
+ # actually set an expiration time on their keys. Otherwise
253
+ # this will be a no-op that simply logs the attempt.
254
+ self.update_expiration(ttl: nil) if update_expiration
223
255
 
224
256
  # The acceptable redis command return values are defined in the
225
257
  # Horreum class. This is to ensure that all commands return values
@@ -256,6 +288,11 @@ module Familia
256
288
  # rocky.destroy!
257
289
  # # => *poof* Rocky is no more. A moment of silence, please.
258
290
  #
291
+ # This method is part of Familia's high-level object lifecycle management. While `delete!`
292
+ # operates directly on Redis keys, `destroy!` operates at the object level and is used for
293
+ # ORM-style operations. Use `destroy!` when removing complete objects from the system, and
294
+ # `delete!` when working directly with Redis keys.
295
+ #
259
296
  # @note If debugging is enabled, this method will leave a trace of its
260
297
  # destructive path, like breadcrumbs for future data archaeologists.
261
298
  #
@@ -266,6 +303,26 @@ module Familia
266
303
  delete!
267
304
  end
268
305
 
306
+ # The Great Nilpocalypse: clear_fields!
307
+ #
308
+ # Imagine your object as a grand old mansion, every room stuffed with
309
+ # trinkets, secrets, and the odd rubber duck. This method? It flings open
310
+ # every window and lets a wild wind of nothingness sweep through, leaving
311
+ # each field as empty as a poet’s wallet.
312
+ #
313
+ # All your precious attributes—gone! Swept into the void! It’s a spring
314
+ # cleaning for the soul, a reset button for your existential dread.
315
+ #
316
+ # @return [void] Nothing left but echoes and nils.
317
+ #
318
+ # @example The Vanishing Act
319
+ # wizard.clear_fields!
320
+ # # => All fields are now nil, like a spell gone slightly too well.
321
+ #
322
+ def clear_fields!
323
+ self.class.fields.each { |field| send("#{field}=", nil) }
324
+ end
325
+
269
326
  # The Great Redis Refresh-o-matic 3000
270
327
  #
271
328
  # Imagine your object as a forgetful time traveler. This method is like
@@ -276,14 +333,19 @@ module Familia
276
333
  # Gone quicker than cake at a hobbit's birthday party. Unsaved spells
277
334
  # will definitely be forgotten.
278
335
  #
279
- # @return What do you get for this daring act of digital amnesia? A shiny
336
+ # @return [void] What do you get for this daring act of digital amnesia? A shiny
280
337
  # list of all the brain bits that got a makeover!
281
338
  #
282
339
  # Remember: In the game of Redis-Refresh, you win or you... well, you
283
340
  # always win, but sometimes you forget why you played in the first place.
284
341
  #
342
+ # @raise [Familia::KeyNotFoundError] If the Redis key does not exist.
343
+ #
344
+ # @example
345
+ # object.refresh!
285
346
  def refresh!
286
347
  Familia.trace :REFRESH, redis, redisuri, caller(1..1) if Familia.debug?
348
+ raise Familia::KeyNotFoundError, rediskey unless redis.exists(rediskey)
287
349
  fields = hgetall
288
350
  Familia.ld "[refresh!] #{self.class} #{rediskey} #{fields.keys}"
289
351
  optimistic_refresh(**fields)
@@ -303,6 +365,8 @@ module Familia
303
365
  # @return [self] Your object, freshly bathed in Redis waters, ready
304
366
  # to dance with more methods in a conga line of Ruby joy!
305
367
  #
368
+ # @raise [Familia::KeyNotFoundError] If the Redis key does not exist.
369
+ #
306
370
  def refresh
307
371
  refresh!
308
372
  self
@@ -326,9 +390,11 @@ module Familia
326
390
  def to_h
327
391
  self.class.fields.inject({}) do |hsh, field|
328
392
  val = send(field)
329
- prepared = to_redis(val)
330
- Familia.ld " [to_h] field: #{field} val: #{val.class} prepared: #{prepared.class}"
331
- hsh[field] = prepared
393
+ prepared = serialize_value(val)
394
+ Familia.ld " [to_h] field: #{field} val: #{val.class} prepared: #{prepared&.class || '[nil]'}"
395
+
396
+ # Only include non-nil values in the hash for Redis
397
+ hsh[field] = prepared unless prepared.nil?
332
398
  hsh
333
399
  end
334
400
  end
@@ -351,7 +417,7 @@ module Familia
351
417
  def to_a
352
418
  self.class.fields.map do |field|
353
419
  val = send(field)
354
- prepared = to_redis(val)
420
+ prepared = serialize_value(val)
355
421
  Familia.ld " [to_a] field: #{field} val: #{val.class} prepared: #{prepared.class}"
356
422
  prepared
357
423
  end
@@ -392,19 +458,49 @@ module Familia
392
458
  #
393
459
  # @return [String] The transformed, Redis-ready value.
394
460
  #
395
- def to_redis(val)
461
+ def serialize_value(val)
396
462
  prepared = Familia.distinguisher(val, strict_values: false)
397
463
 
398
- if prepared.nil? && val.respond_to?(dump_method)
399
- prepared = val.send(dump_method)
464
+ # If the distinguisher returns nil, try using the dump_method but only
465
+ # use JSON serialization for complex types that need it.
466
+ if prepared.nil? && (val.is_a?(Hash) || val.is_a?(Array))
467
+ prepared = val.respond_to?(dump_method) ? val.send(dump_method) : JSON.dump(val)
400
468
  end
401
469
 
470
+ # If both the distinguisher and dump_method return nil, log an error
402
471
  if prepared.nil?
403
- Familia.ld "[#{self.class}#to_redis] nil returned for #{self.class}##{name}"
472
+ Familia.ld "[#{self.class}#serialize_value] nil returned for #{self.class}"
404
473
  end
405
474
 
406
475
  prepared
407
476
  end
477
+ alias to_redis serialize_value
478
+
479
+ # Converts a Redis string value back to its original Ruby type
480
+ #
481
+ # This method attempts to deserialize JSON strings back to their original
482
+ # Hash or Array types. Simple string values are returned as-is.
483
+ #
484
+ # @param val [String] The string value from Redis to deserialize
485
+ # @param symbolize_keys [Boolean] Whether to symbolize hash keys (default: true for compatibility)
486
+ # @return [Object] The deserialized value (Hash, Array, or original string)
487
+ #
488
+ def deserialize_value(val, symbolize: true)
489
+ return val if val.nil? || val == ""
490
+
491
+ # Try to parse as JSON first for complex types
492
+ begin
493
+ parsed = JSON.parse(val, symbolize_names: symbolize)
494
+ # Only return parsed value if it's a complex type (Hash/Array)
495
+ # Simple values should remain as strings
496
+ return parsed if parsed.is_a?(Hash) || parsed.is_a?(Array)
497
+ rescue JSON::ParserError
498
+ # Not valid JSON, return as-is
499
+ end
500
+
501
+ val
502
+ end
503
+ alias from_redis deserialize_value
408
504
 
409
505
  end
410
506
  # End of Serialization module
@@ -465,6 +561,11 @@ module Familia
465
561
  def tuple
466
562
  [successful?, results]
467
563
  end
564
+ alias to_a tuple
565
+
566
+ def to_h
567
+ { success: successful?, results: results }
568
+ end
468
569
 
469
570
  # Convenient method to check if the commit was successful.
470
571
  #
@@ -54,6 +54,10 @@ module Familia
54
54
  # but not existing instances of the class.
55
55
  #
56
56
  class << self
57
+ attr_accessor :parent
58
+ attr_writer :redis, :dump_method, :load_method
59
+ attr_reader :has_relations
60
+
57
61
  # Extends ClassMethods to subclasses and tracks Familia members
58
62
  def inherited(member)
59
63
  Familia.trace :HORREUM, nil, "Welcome #{member} to the family", caller(1..1) if Familia.debug?
@@ -68,7 +72,14 @@ module Familia
68
72
  end
69
73
 
70
74
  # Instance initialization
71
- # This method sets up the object's state, including Redis-related data
75
+ # This method sets up the object's state, including Redis-related data.
76
+ #
77
+ # Usage:
78
+ #
79
+ # Session.new("abc123", "user456") # positional (brittle)
80
+ # Session.new(sessid: "abc123", custid: "user456") # hash (robust)
81
+ # Session.new({sessid: "abc123", custid: "user456"}) # legacy hash (robust)
82
+ #
72
83
  def initialize(*args, **kwargs)
73
84
  Familia.ld "[Horreum] Initializing #{self.class}"
74
85
  initialize_relatives
@@ -77,33 +88,53 @@ module Familia
77
88
  # that every object horreum class has a unique identifier field. Ideally
78
89
  # this logic would live somewhere else b/c we only need to call it once
79
90
  # per class definition. Here it gets called every time an instance is
80
- # instantiated/
91
+ # instantiated.
81
92
  unless self.class.fields.include?(:key)
82
93
  # Define the 'key' field for this class
83
94
  # This approach allows flexibility in how identifiers are generated
84
95
  # while ensuring each object has a consistent way to be referenced
85
- self.class.field :key # , default: -> { identifier }
96
+ self.class.field :key
86
97
  end
87
98
 
88
- # If there are positional arguments, they should be the field
89
- # values in the order they were defined in the implementing class.
99
+ # Detect if first argument is a hash (legacy support)
100
+ if args.size == 1 && args.first.is_a?(Hash) && kwargs.empty?
101
+ kwargs = args.first
102
+ args = []
103
+ end
104
+
105
+ # Initialize object with arguments using one of three strategies:
90
106
  #
91
- # Handle keyword arguments
92
- # Fields is a known quantity, so we iterate over it rather than kwargs
93
- # to ensure that we only set fields that are defined in the class. And
94
- # to avoid runaways.
95
- if args.any?
96
- initialize_with_positional_args(*args)
97
- elsif kwargs.any?
107
+ # 1. **Keyword Arguments** (Recommended): Order-independent field assignment
108
+ # Example: Customer.new(name: "John", email: "john@example.com")
109
+ # - Robust against field reordering
110
+ # - Self-documenting
111
+ # - Only sets provided fields
112
+ #
113
+ # 2. **Positional Arguments** (Legacy): Field assignment by definition order
114
+ # Example: Customer.new("john@example.com", "password123")
115
+ # - Brittle: breaks if field order changes
116
+ # - Compact syntax
117
+ # - Maps to fields in class definition order
118
+ #
119
+ # 3. **No Arguments**: Object created with all fields as nil
120
+ # - Minimal memory footprint in Redis
121
+ # - Fields set on-demand via accessors or save()
122
+ # - Avoids default value conflicts with nil-skipping serialization
123
+ #
124
+ # Note: We iterate over self.class.fields (not kwargs) to ensure only
125
+ # defined fields are set, preventing typos from creating undefined attributes.
126
+ #
127
+ if kwargs.any?
98
128
  initialize_with_keyword_args(**kwargs)
129
+ elsif args.any?
130
+ initialize_with_positional_args(*args)
99
131
  else
100
132
  Familia.ld "[Horreum] #{self.class} initialized with no arguments"
101
- # If there are no arguments, we need to set the default values
102
- # for the fields. This is done in the order they were defined.
103
- # self.class.fields.each do |field|
104
- # default = self.class.defaults[field]
105
- # send(:"#{field}=", default) if default
106
- # end
133
+ # Default values are intentionally NOT set here to:
134
+ # - Maintain Redis memory efficiency (only store non-nil values)
135
+ # - Avoid conflicts with nil-skipping serialization logic
136
+ # - Preserve consistent exists? behavior (empty vs default-filled objects)
137
+ # - Keep initialization lightweight for unused fields
107
138
  end
108
139
 
109
140
  # Implementing classes can define an init method to do any
@@ -199,6 +230,12 @@ module Familia
199
230
  end
200
231
  private :initialize_with_keyword_args
201
232
 
233
+ def initialize_with_keyword_args_from_redis(**fields)
234
+ # Deserialize Redis string values back to their original types
235
+ deserialized_fields = fields.transform_values { |value| deserialize_value(value) }
236
+ initialize_with_keyword_args(**deserialized_fields)
237
+ end
238
+
202
239
  # A thin wrapper around the private initialize method that accepts a field
203
240
  # hash and refreshes the existing object.
204
241
  #
@@ -214,7 +251,7 @@ module Familia
214
251
  # @return [Array] The list of field names that were updated.
215
252
  def optimistic_refresh(**fields)
216
253
  Familia.ld "[optimistic_refresh] #{self.class} #{rediskey} #{fields.keys}"
217
- initialize_with_keyword_args(**fields)
254
+ initialize_with_keyword_args_from_redis(**fields)
218
255
  end
219
256
 
220
257
  # Determines the unique identifier for the instance
@@ -243,6 +280,19 @@ module Familia
243
280
 
244
281
  unique_id
245
282
  end
283
+
284
+ # The principle is: **If Familia objects have `to_s`, then they should work
285
+ # everywhere strings are expected**, including as Redis hash field names.
286
+ def to_s
287
+ # Enable polymorphic string usage for Familia objects
288
+ # This allows passing Familia objects directly where strings are expected
289
+ # without requiring explicit .identifier calls
290
+ identifier.to_s
291
+ rescue => e
292
+ # Fallback for cases where identifier might fail
293
+ Familia.ld "[#{self.class}#to_s] Failed to get identifier: #{e.message}"
294
+ "#<#{self.class}:0x#{object_id.to_s(16)}>"
295
+ end
246
296
  end
247
297
  end
248
298
 
@@ -22,11 +22,14 @@ class Familia::RedisType
22
22
  redis.type rediskey
23
23
  end
24
24
 
25
+ # Deletes the entire Redis key
26
+ # @return [Boolean] true if the key was deleted, false otherwise
25
27
  def delete!
26
- redis.del rediskey
28
+ Familia.trace :DELETE!, redis, redisuri, caller(1..1) if Familia.debug?
29
+ ret = redis.del rediskey
30
+ ret.positive?
27
31
  end
28
32
  alias clear delete!
29
- alias del delete!
30
33
 
31
34
  def exists?
32
35
  redis.exists(rediskey) && !size.zero?
@@ -17,16 +17,16 @@ class Familia::RedisType
17
17
  # serialization.
18
18
  #
19
19
  # @example With a class option
20
- # to_redis(User.new(name: "Cloe"), strict_values: false) #=> '{"name":"Cloe"}'
20
+ # serialize_value(User.new(name: "Cloe"), strict_values: false) #=> '{"name":"Cloe"}'
21
21
  #
22
22
  # @example Without a class option
23
- # to_redis(123) #=> "123"
24
- # to_redis("hello") #=> "hello"
23
+ # serialize_value(123) #=> "123"
24
+ # serialize_value("hello") #=> "hello"
25
25
  #
26
26
  # @raise [Familia::HighRiskFactor] If serialization fails under strict
27
27
  # mode.
28
28
  #
29
- def to_redis(val, strict_values: true)
29
+ def serialize_value(val, strict_values: true)
30
30
  prepared = nil
31
31
 
32
32
  Familia.trace :TOREDIS, redis, "#{val}<#{val.class}|#{opts[:class]}>", caller(1..1) if Familia.debug?
@@ -44,24 +44,26 @@ class Familia::RedisType
44
44
 
45
45
  Familia.trace :TOREDIS, redis, "#{val}<#{val.class}|#{opts[:class]}> => #{prepared}<#{prepared.class}>", caller(1..1) if Familia.debug?
46
46
 
47
- Familia.warn "[#{self.class}\#to_redis] nil returned for #{opts[:class]}\##{name}" if prepared.nil?
47
+ Familia.warn "[#{self.class}\#serialize_value] nil returned for #{opts[:class]}\##{name}" if prepared.nil?
48
48
  prepared
49
49
  end
50
+ alias to_redis serialize_value
50
51
 
51
52
  # Deserializes multiple values from Redis, removing nil values.
52
53
  #
53
54
  # @param values [Array<String>] The values to deserialize.
54
55
  # @return [Array<Object>] Deserialized objects, with nil values removed.
55
56
  #
56
- # @see #multi_from_redis_with_nil
57
+ # @see #deserialize_values_with_nil
57
58
  #
58
- def multi_from_redis(*values)
59
+ def deserialize_values(*values)
59
60
  # Avoid using compact! here. Using compact! as the last expression in the
60
61
  # method can unintentionally return nil if no changes are made, which is
61
62
  # not desirable. Instead, use compact to ensure the method returns the
62
63
  # expected value.
63
- multi_from_redis_with_nil(*values).compact
64
+ deserialize_values_with_nil(*values).compact
64
65
  end
66
+ alias from_redis deserialize_values
65
67
 
66
68
  # Deserializes multiple values from Redis, preserving nil values.
67
69
  #
@@ -75,11 +77,8 @@ class Familia::RedisType
75
77
  # class's load method. If deserialization fails for a value, it's
76
78
  # replaced with nil.
77
79
  #
78
- # NOTE: `multi` in this method name refers to multiple values from
79
- # redis and not the Redis server MULTI command.
80
- #
81
- def multi_from_redis_with_nil(*values)
82
- Familia.ld "multi_from_redis: (#{@opts}) #{values}"
80
+ def deserialize_values_with_nil(*values)
81
+ Familia.ld "deserialize_values: (#{@opts}) #{values}"
83
82
  return [] if values.empty?
84
83
  return values.flatten unless @opts[:class]
85
84
 
@@ -92,7 +91,7 @@ class Familia::RedisType
92
91
 
93
92
  val = @opts[:class].send load_method, obj
94
93
  if val.nil?
95
- Familia.ld "[#{self.class}\#multi_from_redis] nil returned for #{@opts[:class]}\##{name}"
94
+ Familia.ld "[#{self.class}\#deserialize_values] nil returned for #{@opts[:class]}\##{name}"
96
95
  end
97
96
 
98
97
  val
@@ -105,6 +104,7 @@ class Familia::RedisType
105
104
 
106
105
  values
107
106
  end
107
+ alias from_redis_with_nil deserialize_values_with_nil
108
108
 
109
109
  # Deserializes a single value from Redis.
110
110
  #
@@ -120,13 +120,14 @@ class Familia::RedisType
120
120
  # deserialization options that RedisType supports. It uses to_redis
121
121
  # for serialization since everything becomes a string in Redis.
122
122
  #
123
- def from_redis(val)
123
+ def deserialize_value(val)
124
124
  return @opts[:default] if val.nil?
125
125
  return val unless @opts[:class]
126
126
 
127
- ret = multi_from_redis val
127
+ ret = deserialize_values val
128
128
  ret&.first # return the object or nil
129
129
  end
130
+ alias from_redis deserialize_value
130
131
  end
131
132
 
132
133
  end
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Familia
4
+ class HashKey < RedisType
5
+ # Returns the number of fields in the hash
6
+ # @return [Integer] number of fields
7
+ def field_count
8
+ redis.hlen rediskey
9
+ end
10
+ alias size field_count
11
+
12
+ def empty?
13
+ field_count.zero?
14
+ end
15
+
16
+ # +return+ [Integer] Returns 1 if the field is new and added, 0 if the
17
+ # field already existed and the value was updated.
18
+ def []=(field, val)
19
+ ret = redis.hset rediskey, field.to_s, serialize_value(val)
20
+ update_expiration
21
+ ret
22
+ rescue TypeError => e
23
+ Familia.le "[hset]= #{e.message}"
24
+ Familia.ld "[hset]= #{rediskey} #{field}=#{val}" if Familia.debug
25
+ echo :hset, caller(1..1).first if Familia.debug # logs via echo to redis and back
26
+ klass = val.class
27
+ msg = "Cannot store #{field} => #{val.inspect} (#{klass}) in #{rediskey}"
28
+ raise e.class, msg
29
+ end
30
+ alias put []=
31
+ alias store []=
32
+
33
+ def [](field)
34
+ deserialize_value redis.hget(rediskey, field.to_s)
35
+ end
36
+ alias get []
37
+
38
+ def fetch(field, default = nil)
39
+ ret = self[field.to_s]
40
+ if ret.nil?
41
+ raise IndexError, "No such index for: #{field}" if default.nil?
42
+
43
+ default
44
+ else
45
+ ret
46
+ end
47
+ end
48
+
49
+ def keys
50
+ redis.hkeys rediskey
51
+ end
52
+
53
+ def values
54
+ redis.hvals(rediskey).map { |v| deserialize_value v }
55
+ end
56
+
57
+ def hgetall
58
+ redis.hgetall(rediskey).each_with_object({}) do |(k,v), ret|
59
+ ret[k] = deserialize_value v
60
+ end
61
+ end
62
+ alias all hgetall
63
+
64
+ def key?(field)
65
+ redis.hexists rediskey, field.to_s
66
+ end
67
+ alias has_key? key?
68
+ alias include? key?
69
+ alias member? key?
70
+
71
+ # Removes a field from the hash
72
+ # @param field [String] The field to remove
73
+ # @return [Integer] The number of fields that were removed (0 or 1)
74
+ def remove_field(field)
75
+ redis.hdel rediskey, field.to_s
76
+ end
77
+ alias remove remove_field # deprecated
78
+
79
+ def increment(field, by = 1)
80
+ redis.hincrby(rediskey, field.to_s, by).to_i
81
+ end
82
+ alias incr increment
83
+ alias incrby increment
84
+
85
+ def decrement(field, by = 1)
86
+ increment field, -by
87
+ end
88
+ alias decr decrement
89
+ alias decrby decrement
90
+
91
+ def update(hsh = {})
92
+ raise ArgumentError, 'Argument to bulk_set must be a hash' unless hsh.is_a?(Hash)
93
+
94
+ data = hsh.inject([]) { |ret, pair| ret << [pair[0], serialize_value(pair[1])] }.flatten
95
+
96
+ ret = redis.hmset(rediskey, *data)
97
+ update_expiration
98
+ ret
99
+ end
100
+ alias merge! update
101
+
102
+ def values_at *fields
103
+ string_fields = fields.flatten.compact.map(&:to_s)
104
+ elements = redis.hmget(rediskey, *string_fields)
105
+ deserialize_values(*elements)
106
+ end
107
+
108
+ # The Great Redis Refresh-o-matic 3000 for HashKey!
109
+ #
110
+ # This method performs a complete refresh of the hash's state from Redis.
111
+ # It's like giving your hash a memory transfusion - out with the old state,
112
+ # in with the fresh data straight from Redis!
113
+ #
114
+ # @note This operation is atomic - it either succeeds completely or fails
115
+ # safely. Any unsaved changes to the hash will be overwritten.
116
+ #
117
+ # @return [void] Returns nothing, but your hash will be sparkling clean
118
+ # with all its fields synchronized with Redis.
119
+ #
120
+ # @raise [Familia::KeyNotFoundError] If the Redis key for this hash no
121
+ # longer exists. Time travelers beware!
122
+ #
123
+ # @example Basic usage
124
+ # my_hash.refresh! # ZAP! Fresh data loaded
125
+ #
126
+ # @example With error handling
127
+ # begin
128
+ # my_hash.refresh!
129
+ # rescue Familia::KeyNotFoundError
130
+ # puts "Oops! Our hash seems to have vanished into the Redis void!"
131
+ # end
132
+ def refresh!
133
+ Familia.trace :REFRESH, redis, redisuri, caller(1..1) if Familia.debug?
134
+ raise Familia::KeyNotFoundError, rediskey unless redis.exists(rediskey)
135
+
136
+ fields = hgetall
137
+ Familia.ld "[refresh!] #{self.class} #{rediskey} #{fields.keys}"
138
+
139
+ # For HashKey, we update by merging the fresh data
140
+ update(fields)
141
+ end
142
+
143
+ # The friendly neighborhood refresh method!
144
+ #
145
+ # This method is like refresh! but with better manners - it returns self
146
+ # so you can chain it with other methods. It's perfect for when you want
147
+ # to refresh your hash and immediately do something with it.
148
+ #
149
+ # @return [self] Returns the refreshed hash, ready for more adventures!
150
+ #
151
+ # @raise [Familia::KeyNotFoundError] If the Redis key does not exist.
152
+ # The hash must exist in Redis-land for this to work!
153
+ #
154
+ # @example Refresh and chain
155
+ # my_hash.refresh.keys # Refresh and get all keys
156
+ # my_hash.refresh['field'] # Refresh and get a specific field
157
+ #
158
+ # @see #refresh! For the heavy lifting behind the scenes
159
+ def refresh
160
+ refresh!
161
+ self
162
+ end
163
+
164
+ Familia::RedisType.register self, :hash # legacy, deprecated
165
+ Familia::RedisType.register self, :hashkey
166
+ end
167
+ end