familia 0.10.2 → 1.0.0.pre.rc2

Sign up to get free protection for your applications and to get access to all the features.
Files changed (63) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +1 -0
  3. data/.pre-commit-config.yaml +1 -1
  4. data/.rubocop.yml +75 -0
  5. data/.rubocop_todo.yml +63 -0
  6. data/Gemfile +6 -1
  7. data/Gemfile.lock +47 -15
  8. data/README.md +65 -13
  9. data/VERSION.yml +4 -3
  10. data/familia.gemspec +18 -13
  11. data/lib/familia/base.rb +33 -0
  12. data/lib/familia/connection.rb +87 -0
  13. data/lib/familia/core_ext.rb +119 -124
  14. data/lib/familia/errors.rb +33 -0
  15. data/lib/familia/features/api_version.rb +19 -0
  16. data/lib/familia/features/atomic_saves.rb +8 -0
  17. data/lib/familia/features/quantizer.rb +35 -0
  18. data/lib/familia/features/safe_dump.rb +194 -0
  19. data/lib/familia/features.rb +51 -0
  20. data/lib/familia/horreum/class_methods.rb +292 -0
  21. data/lib/familia/horreum/commands.rb +106 -0
  22. data/lib/familia/horreum/relations_management.rb +141 -0
  23. data/lib/familia/horreum/serialization.rb +193 -0
  24. data/lib/familia/horreum/settings.rb +63 -0
  25. data/lib/familia/horreum/utils.rb +44 -0
  26. data/lib/familia/horreum.rb +248 -0
  27. data/lib/familia/logging.rb +232 -0
  28. data/lib/familia/redistype/commands.rb +56 -0
  29. data/lib/familia/redistype/serialization.rb +110 -0
  30. data/lib/familia/redistype.rb +185 -0
  31. data/lib/familia/refinements.rb +88 -0
  32. data/lib/familia/settings.rb +38 -0
  33. data/lib/familia/types/hashkey.rb +107 -0
  34. data/lib/familia/types/list.rb +155 -0
  35. data/lib/familia/types/sorted_set.rb +234 -0
  36. data/lib/familia/types/string.rb +115 -0
  37. data/lib/familia/types/unsorted_set.rb +123 -0
  38. data/lib/familia/utils.rb +125 -0
  39. data/lib/familia/version.rb +25 -0
  40. data/lib/familia.rb +57 -161
  41. data/lib/redis_middleware.rb +109 -0
  42. data/try/00_familia_try.rb +5 -4
  43. data/try/10_familia_try.rb +21 -17
  44. data/try/20_redis_type_try.rb +67 -0
  45. data/try/{21_redis_object_zset_try.rb → 21_redis_type_zset_try.rb} +2 -2
  46. data/try/{22_redis_object_set_try.rb → 22_redis_type_set_try.rb} +2 -2
  47. data/try/{23_redis_object_list_try.rb → 23_redis_type_list_try.rb} +2 -2
  48. data/try/{24_redis_object_string_try.rb → 24_redis_type_string_try.rb} +6 -6
  49. data/try/{25_redis_object_hash_try.rb → 25_redis_type_hash_try.rb} +3 -3
  50. data/try/26_redis_bool_try.rb +10 -6
  51. data/try/27_redis_horreum_try.rb +93 -0
  52. data/try/30_familia_object_try.rb +21 -20
  53. data/try/35_feature_safedump_try.rb +83 -0
  54. data/try/40_customer_try.rb +140 -0
  55. data/try/41_customer_safedump_try.rb +86 -0
  56. data/try/test_helpers.rb +194 -0
  57. metadata +51 -47
  58. data/lib/familia/helpers.rb +0 -70
  59. data/lib/familia/object.rb +0 -533
  60. data/lib/familia/redisobject.rb +0 -1017
  61. data/lib/familia/test_helpers.rb +0 -40
  62. data/lib/familia/tools.rb +0 -67
  63. data/try/20_redis_object_try.rb +0 -44
@@ -0,0 +1,292 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'relations_management'
4
+
5
+ module Familia
6
+ class Horreum
7
+ # Class-level instance variables
8
+ # These are set up as nil initially and populated later
9
+ @redis = nil
10
+ @identifier = nil
11
+ @ttl = nil
12
+ @db = nil
13
+ @uri = nil
14
+ @suffix = nil
15
+ @prefix = nil
16
+ @fields = nil # []
17
+ @class_redis_types = nil # {}
18
+ @redis_types = nil # {}
19
+ @dump_method = nil
20
+ @load_method = nil
21
+
22
+ # ClassMethods: Provides class-level functionality for Horreum
23
+ #
24
+ # This module is extended into classes that include Familia::Horreum,
25
+ # providing methods for Redis operations and object management.
26
+ #
27
+ # Key features:
28
+ # * Includes RelationsManagement for Redis-type field handling
29
+ # * Defines methods for managing fields, identifiers, and Redis keys
30
+ # * Provides utility methods for working with Redis objects
31
+ #
32
+ module ClassMethods
33
+ include Familia::Settings
34
+ include Familia::Horreum::RelationsManagement
35
+
36
+ attr_accessor :parent
37
+ attr_writer :redis, :dump_method, :load_method
38
+
39
+ def redis
40
+ @redis || Familia.redis(uri || db)
41
+ end
42
+
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.
46
+ def identifier(val = nil)
47
+ @identifier = val if val
48
+ @identifier
49
+ end
50
+
51
+ # Define a field for the class. This will create getter and setter
52
+ # instance methods just like any "attr_accessor" methods.
53
+ def field(name)
54
+ fields << name
55
+ attr_accessor name
56
+
57
+ # Every field gets a fast writer method for immediately persisting
58
+ fast_writer! name
59
+ end
60
+
61
+ # @return The return value from redis client for hset command
62
+ 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
68
+ end
69
+ end
70
+
71
+ # Returns the list of field names defined for the class in the order
72
+ # that they were defined. i.e. `field :a; field :b; fields => [:a, :b]`.
73
+ def fields
74
+ @fields ||= []
75
+ @fields
76
+ end
77
+
78
+ def class_redis_types
79
+ @class_redis_types ||= {}
80
+ @class_redis_types
81
+ end
82
+
83
+ def class_redis_types?(name)
84
+ class_redis_types.key? name.to_s.to_sym
85
+ end
86
+
87
+ def redis_object?(name)
88
+ redis_types.key? name.to_s.to_sym
89
+ end
90
+
91
+ def redis_types
92
+ @redis_types ||= {}
93
+ @redis_types
94
+ end
95
+
96
+ def ttl(v = nil)
97
+ @ttl = v unless v.nil?
98
+ @ttl || parent&.ttl
99
+ end
100
+
101
+ def db(v = nil)
102
+ @db = v unless v.nil?
103
+ @db || parent&.db
104
+ end
105
+
106
+ def uri(v = nil)
107
+ @uri = v unless v.nil?
108
+ @uri || parent&.uri
109
+ end
110
+
111
+ def all(suffix = :object)
112
+ # objects that could not be parsed will be nil
113
+ keys(suffix).filter_map { |k| from_key(k) }
114
+ end
115
+
116
+ def any?(filter = '*')
117
+ size(filter) > 0
118
+ end
119
+
120
+ def size(filter = '*')
121
+ redis.keys(rediskey(filter)).compact.size
122
+ end
123
+
124
+ def suffix(a = nil, &blk)
125
+ @suffix = a || blk if a || !blk.nil?
126
+ @suffix || Familia.default_suffix
127
+ end
128
+
129
+ def prefix(a = nil)
130
+ @prefix = a if a
131
+ @prefix || name.downcase.gsub('::', Familia.delim).to_sym
132
+ end
133
+
134
+ def create *args
135
+ me = from_array(*args)
136
+ raise "#{self} exists: #{me.rediskey}" if me.exists?
137
+
138
+ me.save
139
+ me
140
+ end
141
+
142
+ def multiget(*ids)
143
+ ids = rawmultiget(*ids)
144
+ ids.filter_map { |json| from_json(json) }
145
+ end
146
+
147
+ def rawmultiget(*ids)
148
+ ids.collect! { |objid| rediskey(objid) }
149
+ return [] if ids.compact.empty?
150
+
151
+ Familia.trace :MULTIGET, redis, "#{ids.size}: #{ids}", caller if Familia.debug?
152
+ redis.mget(*ids)
153
+ end
154
+
155
+ # Retrieves and instantiates an object from Redis using the full object
156
+ # key.
157
+ #
158
+ # @param objkey [String] The full Redis key for the object.
159
+ # @return [Object, nil] An instance of the class if the key exists, nil
160
+ # otherwise.
161
+ # @raise [ArgumentError] If the provided key is empty.
162
+ #
163
+ # This method performs a two-step process to safely retrieve and
164
+ # instantiate objects:
165
+ #
166
+ # 1. It first checks if the key exists in Redis. This is crucial because:
167
+ # - It provides a definitive answer about the object's existence.
168
+ # - It prevents ambiguity that could arise from `hgetall` returning an
169
+ # empty hash for non-existent keys, which could lead to the creation
170
+ # of "empty" objects.
171
+ #
172
+ # 2. If the key exists, it retrieves the object's data and instantiates
173
+ # it.
174
+ #
175
+ # This approach ensures that we only attempt to instantiate objects that
176
+ # actually exist in Redis, improving reliability and simplifying
177
+ # debugging.
178
+ #
179
+ # @example
180
+ # User.from_key("user:123") # Returns a User instance if it exists,
181
+ # nil otherwise
182
+ #
183
+ def from_key(objkey)
184
+ raise ArgumentError, 'Empty key' if objkey.to_s.empty?
185
+
186
+ # We use a lower-level method here b/c we're working with the
187
+ # full key and not just the identifier.
188
+ does_exist = redis.exists(objkey).positive?
189
+
190
+ Familia.ld "[.from_key] #{self} from key #{objkey} (exists: #{does_exist})"
191
+ Familia.trace :FROM_KEY, redis, objkey, caller if Familia.debug?
192
+
193
+ # This is the reason for calling exists first. We want to definitively
194
+ # and without any ambiguity know if the object exists in Redis. If it
195
+ # doesn't, we return nil. If it does, we proceed to load the object.
196
+ # Otherwise, hgetall will return an empty hash, which will be passed to
197
+ # the constructor, which will then be annoying to debug.
198
+ return unless does_exist
199
+
200
+ obj = redis.hgetall(objkey) # horreum objects are persisted as redis hashes
201
+ Familia.trace :FROM_KEY2, redis, "#{objkey}: #{obj.inspect}", caller if Familia.debug?
202
+
203
+ new(**obj)
204
+ end
205
+
206
+ # Retrieves and instantiates an object from Redis using its identifier.
207
+ #
208
+ # @param identifier [String, Integer] The unique identifier for the
209
+ # object.
210
+ # @param suffix [Symbol] The suffix to use in the Redis key (default:
211
+ # :object).
212
+ # @return [Object, nil] An instance of the class if found, nil otherwise.
213
+ #
214
+ # This method constructs the full Redis key using the provided identifier
215
+ # and suffix, then delegates to `from_key` for the actual retrieval and
216
+ # instantiation.
217
+ #
218
+ # It's a higher-level method that abstracts away the key construction,
219
+ # making it easier to retrieve objects when you only have their
220
+ # identifier.
221
+ #
222
+ # @example
223
+ # User.from_redis(123) # Equivalent to User.from_key("user:123:object")
224
+ #
225
+ def from_redis(identifier, suffix = :object)
226
+ return nil if identifier.to_s.empty?
227
+
228
+ 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
232
+ end
233
+
234
+ def exists?(identifier, suffix = :object)
235
+ return false if identifier.to_s.empty?
236
+
237
+ objkey = rediskey identifier, suffix
238
+
239
+ ret = redis.exists objkey
240
+ Familia.trace :EXISTS, redis, "#{objkey} #{ret.inspect}", caller if Familia.debug?
241
+ ret.positive?
242
+ end
243
+
244
+ def destroy!(identifier, suffix = :object)
245
+ return false if identifier.to_s.empty?
246
+
247
+ objkey = rediskey identifier, suffix
248
+
249
+ ret = redis.del objkey
250
+ if Familia.debug?
251
+ Familia.trace :DELETED, redis, "#{objkey}: #{ret.inspect}",
252
+ caller
253
+ end
254
+ ret.positive?
255
+ end
256
+
257
+ def find(suffix = '*')
258
+ redis.keys(rediskey('*', suffix)) || []
259
+ end
260
+
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
+ # +identifier+ can be a value or an Array of values used to create the index.
269
+ # We don't enforce a default suffix; that's left up to the instance.
270
+ # The suffix is used to differentiate between different types of objects.
271
+ #
272
+ #
273
+ # A nil +suffix+ will not be included in the key.
274
+ def rediskey(identifier, suffix = self.suffix)
275
+ Familia.ld "[.rediskey] #{identifier} for #{self} (suffix:#{suffix})"
276
+ raise NoIdentifier, self if identifier.to_s.empty?
277
+
278
+ identifier &&= identifier.to_s
279
+ Familia.rediskey(prefix, identifier, suffix)
280
+ end
281
+
282
+ def dump_method
283
+ @dump_method || :to_json # Familia.dump_method
284
+ end
285
+
286
+ def load_method
287
+ @load_method || :from_json # Familia.load_method
288
+ end
289
+ end
290
+ # End of ClassMethods module
291
+ end
292
+ end
@@ -0,0 +1,106 @@
1
+ # rubocop:disable all
2
+ #
3
+ module Familia
4
+ # InstanceMethods - Module containing instance-level methods for Familia
5
+ #
6
+ # This module is included in classes that include Familia, providing
7
+ # instance-level functionality for Redis operations and object management.
8
+ #
9
+ class Horreum
10
+
11
+ # Methods that call Redis commands (InstanceMethods)
12
+ #
13
+ # NOTE: There is no hgetall for Horreum. This is because Horreum
14
+ # is a single hash in Redis that we aren't meant to have be working
15
+ # on in memory for more than, making changes -> committing. To
16
+ # emphasize this, instead of "refreshing" the object with hgetall,
17
+ # just load the object again.
18
+ #
19
+ module Commands
20
+
21
+ def exists?
22
+ ret = redis.exists rediskey
23
+ ret.positive?
24
+ end
25
+
26
+ def expire(ttl = nil)
27
+ ttl ||= self.class.ttl
28
+ redis.expire rediskey, ttl.to_i
29
+ end
30
+
31
+ def realttl
32
+ redis.ttl rediskey
33
+ end
34
+
35
+ def hdel!(field)
36
+ redis.hdel rediskey, field
37
+ end
38
+
39
+ def redistype
40
+ redis.type rediskey(suffix)
41
+ end
42
+
43
+ # Parity with RedisType#rename
44
+ def rename(newkey)
45
+ redis.rename rediskey, newkey
46
+ end
47
+
48
+ # For parity with RedisType#hgetall
49
+ def hgetall
50
+ Familia.trace :HGETALL, redis, redisuri, caller(1..1) if Familia.debug?
51
+ redis.hgetall rediskey(suffix)
52
+ end
53
+ alias all hgetall
54
+
55
+ def hget(field)
56
+ redis.hget rediskey(suffix), field
57
+ end
58
+
59
+ # @return The number of fields that were added to the hash. If the
60
+ # field already exists, this will return 0.
61
+ def hset(field, value)
62
+ Familia.trace :HSET, redis, redisuri, caller(1..1) if Familia.debug?
63
+ redis.hset rediskey, field, value
64
+ end
65
+
66
+ def hmset
67
+ redis.hmset rediskey(suffix), self.to_h
68
+ end
69
+
70
+ def hkeys
71
+ Familia.trace :HKEYS, redis, 'redisuri', caller(1..1) if Familia.debug?
72
+ redis.hkeys rediskey(suffix)
73
+ end
74
+
75
+ def hvals
76
+ redis.hvals rediskey(suffix)
77
+ end
78
+
79
+ def hincrby(field, increment)
80
+ redis.hincrby rediskey(suffix), field, increment
81
+ end
82
+
83
+ def hincrbyfloat(field, increment)
84
+ redis.hincrbyfloat rediskey(suffix), field, increment
85
+ end
86
+
87
+ def hlen
88
+ redis.hlen rediskey(suffix)
89
+ end
90
+
91
+ def hstrlen(field)
92
+ redis.hstrlen rediskey(suffix), field
93
+ end
94
+
95
+ def delete!
96
+ Familia.trace :DELETE!, redis, redisuri, caller(1..1) if Familia.debug?
97
+ ret = redis.del rediskey
98
+ ret.positive?
99
+ end
100
+ protected :delete!
101
+
102
+ end
103
+
104
+ include Commands # these become Familia::Horreum instance methods
105
+ end
106
+ end
@@ -0,0 +1,141 @@
1
+ module Familia
2
+ class Horreum
3
+ #
4
+ # RelationsManagement: Manages Redis-type fields and relations
5
+ #
6
+ # This module uses metaprogramming to dynamically create methods
7
+ # for managing different types of Redis objects (e.g., sets, lists, hashes).
8
+ #
9
+ # Key metaprogramming features:
10
+ # * Dynamically defines methods for each Redis type (e.g., set, list, hashkey)
11
+ # * Creates both instance-level and class-level relation methods
12
+ # * Provides query methods for checking relation types
13
+ #
14
+ # Usage:
15
+ # Include this module in classes that need Redis-type management
16
+ # Call setup_relations_accessors to initialize the feature
17
+ #
18
+ module RelationsManagement
19
+ def self.included(base)
20
+ base.extend(ClassMethods)
21
+ base.setup_relations_accessors
22
+ end
23
+
24
+ module ClassMethods
25
+ # Sets up all Redis-type related methods
26
+ # This method is the core of the metaprogramming logic
27
+ #
28
+ def setup_relations_accessors
29
+ Familia::RedisType.registered_types.each_pair do |kind, klass|
30
+ Familia.ld "[registered_types] #{kind} => #{klass}"
31
+
32
+ # Dynamically define instance-level relation methods
33
+ #
34
+ # Once defined, these methods can be used at the class-level of a
35
+ # Familia member to define *instance-level* relations to any of the
36
+ # RedisType types (e.g. set, list, hash, etc).
37
+ #
38
+ define_method :"#{kind}" do |*args|
39
+ name, opts = *args
40
+ attach_instance_redis_object_relation name, klass, opts
41
+ redis_types[name.to_s.to_sym]
42
+ end
43
+ define_method :"#{kind}?" do |name|
44
+ obj = redis_types[name.to_s.to_sym]
45
+ !obj.nil? && klass == obj.klass
46
+ end
47
+ define_method :"#{kind}s" do
48
+ names = redis_types.keys.select { |name| send(:"#{kind}?", name) }
49
+ names.collect! { |name| redis_types[name] }
50
+ names
51
+ end
52
+
53
+ # Dynamically define class-level relation methods
54
+ #
55
+ # Once defined, these methods can be used at the class-level of a
56
+ # Familia member to define *class-level relations* to any of the
57
+ # RedisType types (e.g. class_set, class_list, class_hash, etc).
58
+ #
59
+ define_method :"class_#{kind}" do |*args|
60
+ name, opts = *args
61
+ attach_class_redis_object_relation name, klass, opts
62
+ end
63
+ define_method :"class_#{kind}?" do |name|
64
+ obj = class_redis_types[name.to_s.to_sym]
65
+ !obj.nil? && klass == obj.klass
66
+ end
67
+ define_method :"class_#{kind}s" do
68
+ names = class_redis_types.keys.select { |name| send(:"class_#{kind}?", name) }
69
+ # TODO: This returns instances of the RedisType class which
70
+ # also contain the options. This is different from the instance
71
+ # RedisTypes defined above which returns the Struct of name, klass, and opts.
72
+ # names.collect! { |name| self.send name }
73
+ # OR NOT:
74
+ names.collect! { |name| class_redis_types[name] }
75
+ names
76
+ end
77
+ end
78
+ end
79
+ end
80
+ # End of ClassMethods module
81
+
82
+ # Creates an instance-level relation
83
+ def attach_instance_redis_object_relation(name, klass, opts)
84
+ Familia.ld "[Attaching instance-level #{name}] #{klass} => (#{self}) #{opts}"
85
+ raise ArgumentError, "Name is blank (#{klass})" if name.to_s.empty?
86
+
87
+ name = name.to_s.to_sym
88
+ opts ||= {}
89
+
90
+ redis_types[name] = Struct.new(:name, :klass, :opts).new
91
+ redis_types[name].name = name
92
+ redis_types[name].klass = klass
93
+ redis_types[name].opts = opts
94
+
95
+ attr_reader name
96
+
97
+ define_method :"#{name}=" do |val|
98
+ send(name).replace val
99
+ end
100
+ define_method :"#{name}?" do
101
+ !send(name).empty?
102
+ end
103
+
104
+ redis_types[name]
105
+ end
106
+
107
+ # Creates a class-level relation
108
+ def attach_class_redis_object_relation(name, klass, opts)
109
+ Familia.ld "[#{self}] Attaching class-level #{name} #{klass} => #{opts}"
110
+ raise ArgumentError, 'Name is blank (klass)' if name.to_s.empty?
111
+
112
+ name = name.to_s.to_sym
113
+ opts = opts.nil? ? {} : opts.clone
114
+ opts[:parent] = self unless opts.key?(:parent)
115
+
116
+ class_redis_types[name] = Struct.new(:name, :klass, :opts).new
117
+ class_redis_types[name].name = name
118
+ class_redis_types[name].klass = klass
119
+ class_redis_types[name].opts = opts
120
+
121
+ # An accessor method created in the metaclass will
122
+ # access the instance variables for this class.
123
+ singleton_class.attr_reader name
124
+
125
+ define_singleton_method :"#{name}=" do |v|
126
+ send(name).replace v
127
+ end
128
+ define_singleton_method :"#{name}?" do
129
+ !send(name).empty?
130
+ end
131
+
132
+ redis_object = klass.new name, opts
133
+ redis_object.freeze
134
+ instance_variable_set(:"@#{name}", redis_object)
135
+
136
+ class_redis_types[name]
137
+ end
138
+ end
139
+ # End of RelationsManagement module
140
+ end
141
+ end