familia 1.0.0.pre.rc2 → 1.0.0.pre.rc4

Sign up to get free protection for your applications and to get access to all the features.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +1 -1
  3. data/README.md +198 -48
  4. data/VERSION.yml +1 -1
  5. data/lib/familia/base.rb +29 -1
  6. data/lib/familia/features/expiration.rb +90 -0
  7. data/lib/familia/features/quantization.rb +56 -0
  8. data/lib/familia/features/safe_dump.rb +2 -33
  9. data/lib/familia/features.rb +5 -4
  10. data/lib/familia/horreum/class_methods.rb +172 -53
  11. data/lib/familia/horreum/commands.rb +43 -5
  12. data/lib/familia/horreum/relations_management.rb +2 -2
  13. data/lib/familia/horreum/serialization.rb +172 -47
  14. data/lib/familia/horreum/settings.rb +0 -8
  15. data/lib/familia/horreum/utils.rb +0 -1
  16. data/lib/familia/horreum.rb +1 -1
  17. data/lib/familia/logging.rb +26 -4
  18. data/lib/familia/redistype/serialization.rb +60 -38
  19. data/lib/familia/redistype.rb +45 -17
  20. data/lib/familia/settings.rb +11 -1
  21. data/lib/familia/tools.rb +68 -0
  22. data/lib/familia/types/hashkey.rb +9 -8
  23. data/lib/familia/types/list.rb +4 -2
  24. data/lib/familia/types/sorted_set.rb +12 -12
  25. data/lib/familia/types/string.rb +1 -1
  26. data/lib/familia/types/unsorted_set.rb +2 -2
  27. data/lib/familia/utils.rb +106 -51
  28. data/lib/familia/version.rb +2 -2
  29. data/try/10_familia_try.rb +4 -4
  30. data/try/20_redis_type_try.rb +9 -6
  31. data/try/26_redis_bool_try.rb +1 -1
  32. data/try/27_redis_horreum_try.rb +1 -1
  33. data/try/30_familia_object_try.rb +3 -2
  34. data/try/40_customer_try.rb +3 -3
  35. data/try/test_helpers.rb +9 -2
  36. metadata +5 -5
  37. data/lib/familia/features/api_version.rb +0 -19
  38. data/lib/familia/features/atomic_saves.rb +0 -8
  39. data/lib/familia/features/quantizer.rb +0 -35
@@ -36,20 +36,40 @@ module Familia
36
36
  attr_accessor :parent
37
37
  attr_writer :redis, :dump_method, :load_method
38
38
 
39
+ # Returns the Redis connection for the class.
40
+ #
41
+ # This method retrieves the Redis connection instance for the class. If no
42
+ # connection is set, it initializes a new connection using the provided URI
43
+ # or database configuration.
44
+ #
45
+ # @return [Redis] the Redis connection instance.
46
+ #
39
47
  def redis
40
48
  @redis || Familia.redis(uri || db)
41
49
  end
42
50
 
43
- # The object field or instance method to call to get the unique identifier
44
- # for that instance. The value returned by this method will be used to
45
- # generate the key for the object in Redis.
51
+ # Sets or retrieves the unique identifier for the class.
52
+ #
53
+ # This method defines or returns the unique identifier used to generate the
54
+ # Redis key for the object. If a value is provided, it sets the identifier;
55
+ # otherwise, it returns the current identifier.
56
+ #
57
+ # @param [Object] val the value to set as the identifier (optional).
58
+ # @return [Object] the current identifier.
59
+ #
46
60
  def identifier(val = nil)
47
61
  @identifier = val if val
48
62
  @identifier
49
63
  end
50
64
 
51
- # Define a field for the class. This will create getter and setter
52
- # instance methods just like any "attr_accessor" methods.
65
+ # Defines a field for the class and creates accessor methods.
66
+ #
67
+ # This method defines a new field for the class, creating getter and setter
68
+ # instance methods similar to `attr_accessor`. It also generates a fast
69
+ # writer method for immediate persistence to Redis.
70
+ #
71
+ # @param [Symbol, String] name the name of the field to define.
72
+ #
53
73
  def field(name)
54
74
  fields << name
55
75
  attr_accessor name
@@ -58,13 +78,44 @@ module Familia
58
78
  fast_writer! name
59
79
  end
60
80
 
61
- # @return The return value from redis client for hset command
81
+ # Defines a writer method with a bang (!) suffix for a given attribute name.
82
+ #
83
+ # The dynamically defined method performs the following:
84
+ # - Checks if the correct number of arguments is provided (exactly one).
85
+ # - Converts the provided value to a format suitable for Redis storage.
86
+ # - Uses the existing accessor method to set the attribute value.
87
+ # - Persists the value to Redis immediately using the hset command.
88
+ # - Includes custom error handling to raise an ArgumentError if the wrong number of arguments is given.
89
+ # - Raises a custom error message if an exception occurs during the execution of the method.
90
+ #
91
+ # @param [Symbol, String] name the name of the attribute for which the writer method is defined.
92
+ # @raise [ArgumentError] if the wrong number of arguments is provided.
93
+ # @raise [RuntimeError] if an exception occurs during the execution of the method.
94
+ #
62
95
  def fast_writer!(name)
63
- define_method :"#{name}!" do |value|
64
- prepared = to_redis(value)
65
- Familia.ld "[.fast_writer!] #{name} val: #{value.class} prepared: #{prepared.class}"
66
- send :"#{name}=", value # use the existing accessor
67
- hset name, prepared # persist to Redis without delay
96
+ define_method :"#{name}!" do |*args|
97
+ # Check if the correct number of arguments is provided (exactly one).
98
+ raise ArgumentError, "wrong number of arguments (given #{args.size}, expected 1)" if args.size != 1
99
+
100
+ value = args.first
101
+
102
+ begin
103
+ # Trace the operation if debugging is enabled.
104
+ Familia.trace :FAST_WRITER, redis, "#{name}: #{value.inspect}", caller(1..1) if Familia.debug?
105
+
106
+ # Convert the provided value to a format suitable for Redis storage.
107
+ prepared = to_redis(value)
108
+ Familia.ld "[.fast_writer!] #{name} val: #{value.class} prepared: #{prepared.class}"
109
+
110
+ # Use the existing accessor method to set the attribute value.
111
+ send :"#{name}=", value
112
+
113
+ # Persist the value to Redis immediately using the hset command.
114
+ hset name, prepared
115
+ rescue Familia::Problem => e
116
+ # Raise a custom error message if an exception occurs during the execution of the method.
117
+ raise "#{name}! method failed: #{e.message}", e.backtrace
118
+ end
68
119
  end
69
120
  end
70
121
 
@@ -93,11 +144,6 @@ module Familia
93
144
  @redis_types
94
145
  end
95
146
 
96
- def ttl(v = nil)
97
- @ttl = v unless v.nil?
98
- @ttl || parent&.ttl
99
- end
100
-
101
147
  def db(v = nil)
102
148
  @db = v unless v.nil?
103
149
  @db || parent&.db
@@ -108,9 +154,10 @@ module Familia
108
154
  @uri || parent&.uri
109
155
  end
110
156
 
111
- def all(suffix = :object)
157
+ def all(suffix = nil)
158
+ suffix ||= self.suffix
112
159
  # objects that could not be parsed will be nil
113
- keys(suffix).filter_map { |k| from_key(k) }
160
+ keys(suffix).filter_map { |k| from_rediskey(k) }
114
161
  end
115
162
 
116
163
  def any?(filter = '*')
@@ -131,12 +178,48 @@ module Familia
131
178
  @prefix || name.downcase.gsub('::', Familia.delim).to_sym
132
179
  end
133
180
 
134
- def create *args
135
- me = from_array(*args)
136
- raise "#{self} exists: #{me.rediskey}" if me.exists?
181
+ # Creates and persists a new instance of the class.
182
+ #
183
+ # @param *args [Array] Variable number of positional arguments to be passed
184
+ # to the constructor.
185
+ # @param **kwargs [Hash] Keyword arguments to be passed to the constructor.
186
+ # @return [Object] The newly created and persisted instance.
187
+ # @raise [Familia::Problem] If an instance with the same identifier already
188
+ # exists.
189
+ #
190
+ # This method serves as a factory method for creating and persisting new
191
+ # instances of the class. It combines object instantiation, existence
192
+ # checking, and persistence in a single operation.
193
+ #
194
+ # The method is flexible in accepting both positional and keyword arguments:
195
+ # - Positional arguments (*args) are passed directly to the constructor.
196
+ # - Keyword arguments (**kwargs) are passed as a hash to the constructor.
197
+ #
198
+ # After instantiation, the method checks if an object with the same
199
+ # identifier already exists. If it does, a Familia::Problem exception is
200
+ # raised to prevent overwriting existing data.
201
+ #
202
+ # Finally, the method saves the new instance returns it.
203
+ #
204
+ # @example Creating an object with keyword arguments
205
+ # User.create(name: "John", age: 30)
206
+ #
207
+ # @example Creating an object with positional and keyword arguments (not recommended)
208
+ # Product.create("SKU123", name: "Widget", price: 9.99)
209
+ #
210
+ # @note The behavior of this method depends on the implementation of #new,
211
+ # #exists?, and #save in the class and its superclasses.
212
+ #
213
+ # @see #new
214
+ # @see #exists?
215
+ # @see #save
216
+ #
217
+ def create *args, **kwargs
218
+ fobj = new(*args, **kwargs)
219
+ raise Familia::Problem, "#{self} already exists: #{fobj.rediskey}" if fobj.exists?
137
220
 
138
- me.save
139
- me
221
+ fobj.save
222
+ fobj
140
223
  end
141
224
 
142
225
  def multiget(*ids)
@@ -148,7 +231,7 @@ module Familia
148
231
  ids.collect! { |objid| rediskey(objid) }
149
232
  return [] if ids.compact.empty?
150
233
 
151
- Familia.trace :MULTIGET, redis, "#{ids.size}: #{ids}", caller if Familia.debug?
234
+ Familia.trace :MULTIGET, redis, "#{ids.size}: #{ids}", caller(1..1) if Familia.debug?
152
235
  redis.mget(*ids)
153
236
  end
154
237
 
@@ -177,18 +260,18 @@ module Familia
177
260
  # debugging.
178
261
  #
179
262
  # @example
180
- # User.from_key("user:123") # Returns a User instance if it exists,
263
+ # User.from_rediskey("user:123") # Returns a User instance if it exists,
181
264
  # nil otherwise
182
265
  #
183
- def from_key(objkey)
266
+ def from_rediskey(objkey)
184
267
  raise ArgumentError, 'Empty key' if objkey.to_s.empty?
185
268
 
186
269
  # We use a lower-level method here b/c we're working with the
187
270
  # full key and not just the identifier.
188
271
  does_exist = redis.exists(objkey).positive?
189
272
 
190
- Familia.ld "[.from_key] #{self} from key #{objkey} (exists: #{does_exist})"
191
- Familia.trace :FROM_KEY, redis, objkey, caller if Familia.debug?
273
+ Familia.ld "[.from_rediskey] #{self} from key #{objkey} (exists: #{does_exist})"
274
+ Familia.trace :FROM_KEY, redis, objkey, caller(1..1) if Familia.debug?
192
275
 
193
276
  # This is the reason for calling exists first. We want to definitively
194
277
  # and without any ambiguity know if the object exists in Redis. If it
@@ -198,7 +281,7 @@ module Familia
198
281
  return unless does_exist
199
282
 
200
283
  obj = redis.hgetall(objkey) # horreum objects are persisted as redis hashes
201
- Familia.trace :FROM_KEY2, redis, "#{objkey}: #{obj.inspect}", caller if Familia.debug?
284
+ Familia.trace :FROM_KEY2, redis, "#{objkey}: #{obj.inspect}", caller(1..1) if Familia.debug?
202
285
 
203
286
  new(**obj)
204
287
  end
@@ -212,7 +295,7 @@ module Familia
212
295
  # @return [Object, nil] An instance of the class if found, nil otherwise.
213
296
  #
214
297
  # This method constructs the full Redis key using the provided identifier
215
- # and suffix, then delegates to `from_key` for the actual retrieval and
298
+ # and suffix, then delegates to `from_rediskey` for the actual retrieval and
216
299
  # instantiation.
217
300
  #
218
301
  # It's a higher-level method that abstracts away the key construction,
@@ -220,59 +303,95 @@ module Familia
220
303
  # identifier.
221
304
  #
222
305
  # @example
223
- # User.from_redis(123) # Equivalent to User.from_key("user:123:object")
306
+ # User.from_identifier(123) # Equivalent to User.from_rediskey("user:123:object")
224
307
  #
225
- def from_redis(identifier, suffix = :object)
308
+ def from_identifier(identifier, suffix = nil)
309
+ suffix ||= self.suffix
226
310
  return nil if identifier.to_s.empty?
227
311
 
228
312
  objkey = rediskey(identifier, suffix)
229
- Familia.ld "[.from_redis] #{self} from key #{objkey})"
230
- Familia.trace :FROM_REDIS, Familia.redis(uri), objkey, caller(1..1).first if Familia.debug?
231
- from_key objkey
313
+
314
+ Familia.ld "[.from_identifier] #{self} from key #{objkey})"
315
+ Familia.trace :FROM_IDENTIFIER, Familia.redis(uri), objkey, caller(1..1).first if Familia.debug?
316
+ from_rediskey objkey
232
317
  end
318
+ alias load from_identifier
233
319
 
234
- def exists?(identifier, suffix = :object)
320
+ # Checks if an object with the given identifier exists in Redis.
321
+ #
322
+ # @param identifier [String, Integer] The unique identifier for the object.
323
+ # @param suffix [Symbol, nil] The suffix to use in the Redis key (default: class suffix).
324
+ # @return [Boolean] true if the object exists, false otherwise.
325
+ #
326
+ # This method constructs the full Redis key using the provided identifier and suffix,
327
+ # then checks if the key exists in Redis.
328
+ #
329
+ # @example
330
+ # User.exists?(123) # Returns true if user:123:object exists in Redis
331
+ #
332
+ def exists?(identifier, suffix = nil)
333
+ suffix ||= self.suffix
235
334
  return false if identifier.to_s.empty?
236
335
 
237
336
  objkey = rediskey identifier, suffix
238
337
 
239
338
  ret = redis.exists objkey
240
- Familia.trace :EXISTS, redis, "#{objkey} #{ret.inspect}", caller if Familia.debug?
241
- ret.positive?
339
+ Familia.trace :EXISTS, redis, "#{objkey} #{ret.inspect}", caller(1..1) if Familia.debug?
340
+
341
+ ret.positive? # differs from redis API but I think it's okay bc `exists?` is a predicate method.
242
342
  end
243
343
 
244
- def destroy!(identifier, suffix = :object)
344
+ # Destroys an object in Redis with the given identifier.
345
+ #
346
+ # @param identifier [String, Integer] The unique identifier for the object to destroy.
347
+ # @param suffix [Symbol, nil] The suffix to use in the Redis key (default: class suffix).
348
+ # @return [Boolean] true if the object was successfully destroyed, false otherwise.
349
+ #
350
+ # This method constructs the full Redis key using the provided identifier and suffix,
351
+ # then removes the corresponding key from Redis.
352
+ #
353
+ # @example
354
+ # User.destroy!(123) # Removes user:123:object from Redis
355
+ #
356
+ def destroy!(identifier, suffix = nil)
357
+ suffix ||= self.suffix
245
358
  return false if identifier.to_s.empty?
246
359
 
247
360
  objkey = rediskey identifier, suffix
248
361
 
249
362
  ret = redis.del objkey
250
- if Familia.debug?
251
- Familia.trace :DELETED, redis, "#{objkey}: #{ret.inspect}",
252
- caller
253
- end
363
+ Familia.trace :DELETED, redis, "#{objkey}: #{ret.inspect}", caller(1..1) if Familia.debug?
254
364
  ret.positive?
255
365
  end
256
366
 
367
+ # Finds all keys in Redis matching the given suffix pattern.
368
+ #
369
+ # @param suffix [String] The suffix pattern to match (default: '*').
370
+ # @return [Array<String>] An array of matching Redis keys.
371
+ #
372
+ # This method searches for all Redis keys that match the given suffix pattern.
373
+ # It uses the class's rediskey method to construct the search pattern.
374
+ #
375
+ # @example
376
+ # User.find # Returns all keys matching user:*:object
377
+ # User.find('active') # Returns all keys matching user:*:active
378
+ #
257
379
  def find(suffix = '*')
258
380
  redis.keys(rediskey('*', suffix)) || []
259
381
  end
260
382
 
261
- def qstamp(quantum = nil, pattern = nil, now = Familia.now)
262
- quantum ||= ttl || 10.minutes
263
- pattern ||= '%H%M'
264
- rounded = now - (now % quantum)
265
- Time.at(rounded).utc.strftime(pattern)
266
- end
267
-
268
383
  # +identifier+ can be a value or an Array of values used to create the index.
269
384
  # We don't enforce a default suffix; that's left up to the instance.
270
385
  # The suffix is used to differentiate between different types of objects.
271
386
  #
272
- #
273
- # A nil +suffix+ will not be included in the key.
387
+ # +suffix+ If a nil value is explicitly passed in, it won't appear in the redis
388
+ # key that's returned. If no suffix is passed in, the class' suffix is used
389
+ # as the default (via the class method self.suffix). It's an important
390
+ # distinction b/c passing in an explicitly nil is how RedisType objects
391
+ # at the class level are created without the global default 'object'
392
+ # suffix. See RedisType#rediskey "parent_class?" for more details.
274
393
  def rediskey(identifier, suffix = self.suffix)
275
- Familia.ld "[.rediskey] #{identifier} for #{self} (suffix:#{suffix})"
394
+ # Familia.ld "[.rediskey] #{identifier} for #{self} (suffix:#{suffix})"
276
395
  raise NoIdentifier, self if identifier.to_s.empty?
277
396
 
278
397
  identifier &&= identifier.to_s
@@ -19,29 +19,42 @@ module Familia
19
19
  module Commands
20
20
 
21
21
  def exists?
22
- ret = redis.exists rediskey
23
- ret.positive?
22
+ # Trace output comes from the class method
23
+ self.class.exists? identifier, suffix
24
24
  end
25
25
 
26
+ # Sets a timeout on key. After the timeout has expired, the key will automatically be deleted.
27
+ # Returns 1 if the timeout was set, 0 if key does not exist or the timeout could not be set.
28
+ #
26
29
  def expire(ttl = nil)
27
30
  ttl ||= self.class.ttl
31
+ Familia.trace :EXPIRE, redis, ttl, caller(1..1) if Familia.debug?
28
32
  redis.expire rediskey, ttl.to_i
29
33
  end
30
34
 
31
35
  def realttl
36
+ Familia.trace :REALTTL, redis, redisuri, caller(1..1) if Familia.debug?
32
37
  redis.ttl rediskey
33
38
  end
34
39
 
40
+ # Deletes a field from the hash stored at the Redis key.
41
+ #
42
+ # @param field [String] The field to delete from the hash.
43
+ # @return [Integer] The number of fields that were removed from the hash (0 or 1).
44
+ # @note This method is destructive, as indicated by the bang (!).
35
45
  def hdel!(field)
46
+ Familia.trace :HDEL, redis, field, caller(1..1) if Familia.debug?
36
47
  redis.hdel rediskey, field
37
48
  end
38
49
 
39
50
  def redistype
51
+ Familia.trace :REDISTYPE, redis, redisuri, caller(1..1) if Familia.debug?
40
52
  redis.type rediskey(suffix)
41
53
  end
42
54
 
43
55
  # Parity with RedisType#rename
44
56
  def rename(newkey)
57
+ Familia.trace :RENAME, redis, "#{rediskey} -> #{newkey}", caller(1..1) if Familia.debug?
45
58
  redis.rename rediskey, newkey
46
59
  end
47
60
 
@@ -53,13 +66,14 @@ module Familia
53
66
  alias all hgetall
54
67
 
55
68
  def hget(field)
69
+ Familia.trace :HGET, redis, field, caller(1..1) if Familia.debug?
56
70
  redis.hget rediskey(suffix), field
57
71
  end
58
72
 
59
73
  # @return The number of fields that were added to the hash. If the
60
74
  # field already exists, this will return 0.
61
75
  def hset(field, value)
62
- Familia.trace :HSET, redis, redisuri, caller(1..1) if Familia.debug?
76
+ Familia.trace :HSET, redis, field, caller(1..1) if Familia.debug?
63
77
  redis.hset rediskey, field, value
64
78
  end
65
79
 
@@ -76,21 +90,45 @@ module Familia
76
90
  redis.hvals rediskey(suffix)
77
91
  end
78
92
 
79
- def hincrby(field, increment)
93
+ def incr(field)
94
+ redis.hincrby rediskey(suffix), field, 1
95
+ end
96
+ alias increment incr
97
+
98
+ def incrby(field, increment)
80
99
  redis.hincrby rediskey(suffix), field, increment
81
100
  end
101
+ alias incrementby incrby
82
102
 
83
- def hincrbyfloat(field, increment)
103
+ def incrbyfloat(field, increment)
84
104
  redis.hincrbyfloat rediskey(suffix), field, increment
85
105
  end
106
+ alias incrementbyfloat incrbyfloat
107
+
108
+ def decrby(field, decrement)
109
+ redis.decrby rediskey(suffix), field, decrement
110
+ end
111
+ alias decrementby decrby
112
+
113
+ def decr(field)
114
+ redis.hdecr field
115
+ end
116
+ alias decrement decr
86
117
 
87
118
  def hlen
88
119
  redis.hlen rediskey(suffix)
89
120
  end
121
+ alias hlength hlen
90
122
 
91
123
  def hstrlen(field)
92
124
  redis.hstrlen rediskey(suffix), field
93
125
  end
126
+ alias hstrlength hstrlen
127
+
128
+ def key?(field)
129
+ redis.hexists rediskey(suffix), field
130
+ end
131
+ alias has_key? key?
94
132
 
95
133
  def delete!
96
134
  Familia.trace :DELETE!, redis, redisuri, caller(1..1) if Familia.debug?
@@ -81,7 +81,7 @@ module Familia
81
81
 
82
82
  # Creates an instance-level relation
83
83
  def attach_instance_redis_object_relation(name, klass, opts)
84
- Familia.ld "[Attaching instance-level #{name}] #{klass} => (#{self}) #{opts}"
84
+ Familia.ld "[#{self}##{name}] Attaching instance-level #{klass} #{opts}"
85
85
  raise ArgumentError, "Name is blank (#{klass})" if name.to_s.empty?
86
86
 
87
87
  name = name.to_s.to_sym
@@ -106,7 +106,7 @@ module Familia
106
106
 
107
107
  # Creates a class-level relation
108
108
  def attach_class_redis_object_relation(name, klass, opts)
109
- Familia.ld "[#{self}] Attaching class-level #{name} #{klass} => #{opts}"
109
+ Familia.ld "[#{self}.#{name}] Attaching class-level #{klass} #{opts}"
110
110
  raise ArgumentError, 'Name is blank (klass)' if name.to_s.empty?
111
111
 
112
112
  name = name.to_s.to_sym