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.
- checksums.yaml +4 -4
- data/.gitignore +1 -0
- data/.pre-commit-config.yaml +1 -1
- data/Gemfile +3 -1
- data/Gemfile.lock +3 -1
- data/README.md +5 -5
- data/VERSION.yml +1 -2
- data/familia.gemspec +1 -2
- data/lib/familia/base.rb +11 -11
- data/lib/familia/connection.rb +18 -2
- data/lib/familia/errors.rb +14 -0
- data/lib/familia/features/expiration.rb +13 -2
- data/lib/familia/features/quantization.rb +1 -1
- data/lib/familia/horreum/class_methods.rb +31 -21
- data/lib/familia/horreum/commands.rb +58 -15
- data/lib/familia/horreum/relations_management.rb +9 -2
- data/lib/familia/horreum/serialization.rb +130 -29
- data/lib/familia/horreum.rb +69 -19
- data/lib/familia/redistype/commands.rb +5 -2
- data/lib/familia/redistype/serialization.rb +17 -16
- data/lib/familia/redistype/types/hashkey.rb +167 -0
- data/lib/familia/{types → redistype/types}/list.rb +19 -14
- data/lib/familia/{types → redistype/types}/sorted_set.rb +22 -19
- data/lib/familia/{types → redistype/types}/string.rb +8 -6
- data/lib/familia/{types → redistype/types}/unsorted_set.rb +16 -12
- data/lib/familia/redistype.rb +19 -9
- data/lib/familia.rb +6 -1
- data/try/10_familia_try.rb +1 -1
- data/try/20_redis_type_try.rb +1 -1
- data/try/21_redis_type_zset_try.rb +1 -1
- data/try/22_redis_type_set_try.rb +1 -1
- data/try/23_redis_type_list_try.rb +2 -2
- data/try/24_redis_type_string_try.rb +3 -3
- data/try/25_redis_type_hash_try.rb +1 -1
- data/try/26_redis_bool_try.rb +2 -2
- data/try/27_redis_horreum_try.rb +2 -2
- data/try/28_redis_horreum_serialization_try.rb +159 -0
- data/try/29_redis_horreum_initialization_try.rb +113 -0
- data/try/30_familia_object_try.rb +8 -5
- data/try/40_customer_try.rb +6 -6
- data/try/91_json_bug_try.rb +86 -0
- data/try/92_symbolize_try.rb +96 -0
- data/try/93_string_coercion_try.rb +154 -0
- metadata +20 -15
- 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
|
-
|
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
|
-
|
133
|
-
self.
|
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 =
|
330
|
-
Familia.ld " [to_h] field: #{field} val: #{val.class} prepared: #{prepared
|
331
|
-
|
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 =
|
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
|
461
|
+
def serialize_value(val)
|
396
462
|
prepared = Familia.distinguisher(val, strict_values: false)
|
397
463
|
|
398
|
-
|
399
|
-
|
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}#
|
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
|
#
|
data/lib/familia/horreum.rb
CHANGED
@@ -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
|
96
|
+
self.class.field :key
|
86
97
|
end
|
87
98
|
|
88
|
-
#
|
89
|
-
|
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
|
-
#
|
92
|
-
#
|
93
|
-
#
|
94
|
-
#
|
95
|
-
|
96
|
-
|
97
|
-
|
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
|
-
#
|
102
|
-
#
|
103
|
-
#
|
104
|
-
#
|
105
|
-
#
|
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
|
-
|
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.
|
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
|
-
#
|
20
|
+
# serialize_value(User.new(name: "Cloe"), strict_values: false) #=> '{"name":"Cloe"}'
|
21
21
|
#
|
22
22
|
# @example Without a class option
|
23
|
-
#
|
24
|
-
#
|
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
|
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}\#
|
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 #
|
57
|
+
# @see #deserialize_values_with_nil
|
57
58
|
#
|
58
|
-
def
|
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
|
-
|
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
|
-
|
79
|
-
|
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}\#
|
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
|
123
|
+
def deserialize_value(val)
|
124
124
|
return @opts[:default] if val.nil?
|
125
125
|
return val unless @opts[:class]
|
126
126
|
|
127
|
-
ret =
|
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
|