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,193 @@
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 load and dump (InstanceMethods)
12
+ #
13
+ # Note on refresh methods:
14
+ # In this class, refresh! is the primary method that performs the Redis
15
+ # query and state update. The non-bang refresh method is provided as a
16
+ # convenience for method chaining, but still performs the same destructive
17
+ # update as refresh!. This deviates from common Ruby conventions to better
18
+ # fit the specific needs of this system.
19
+ module Serialization
20
+ #include Familia::RedisType::Serialization
21
+
22
+ attr_writer :redis
23
+
24
+ def redis
25
+ @redis || self.class.redis
26
+ end
27
+
28
+ def transaction
29
+ original_redis = self.redis
30
+
31
+ begin
32
+ redis.multi do |conn|
33
+ self.instance_variable_set(:@redis, conn)
34
+ yield(conn)
35
+ end
36
+ ensure
37
+ self.redis = original_redis
38
+ end
39
+ end
40
+
41
+ # A thin wrapper around `commit_fields` that updates the timestamps and
42
+ # returns a boolean.
43
+ def save
44
+ Familia.trace :SAVE, redis, redisuri, caller(1..1) if Familia.debug?
45
+
46
+ # Update timestamp fields
47
+ self.key ||= self.identifier
48
+ self.updated = Familia.now.to_i
49
+ self.created ||= Familia.now.to_i
50
+
51
+ # Thr return value of commit_fields is an array of strings: ["OK"].
52
+ ret = commit_fields # e.g. ["OK"]
53
+
54
+ Familia.ld "[save] #{self.class} #{rediskey} #{ret}"
55
+
56
+ # Convert the return value to a boolean
57
+ ret.all? { |value| value == "OK" }
58
+ end
59
+
60
+ # +return: [Array<String>] The return value of the Redis multi command
61
+ def commit_fields
62
+ Familia.ld "[commit_fields] #{self.class} #{rediskey} #{to_h}"
63
+ transaction do |conn|
64
+ hmset
65
+ update_expiration
66
+ end
67
+ end
68
+
69
+ def destroy!
70
+ Familia.trace :DESTROY, redis, redisuri, caller(1..1) if Familia.debug?
71
+ delete!
72
+ end
73
+
74
+ # Refreshes the object's state by querying Redis and overwriting the
75
+ # current field values. This method performs a destructive update on the
76
+ # object, regardless of unsaved changes.
77
+ #
78
+ # @note This is a destructive operation that will overwrite any unsaved
79
+ # changes.
80
+ # @return The list of field names that were updated.
81
+ def refresh!
82
+ Familia.trace :REFRESH, redis, redisuri, caller(1..1) if Familia.debug?
83
+ fields = hgetall
84
+ Familia.ld "[refresh!] #{self.class} #{rediskey} #{fields.keys}"
85
+ optimistic_refresh(**fields)
86
+ end
87
+
88
+ # Refreshes the object's state and returns self to allow method chaining.
89
+ # This method calls refresh! internally, performing the actual Redis
90
+ # query and state update.
91
+ #
92
+ # @note While this method allows chaining, it still performs a
93
+ # destructive update like refresh!.
94
+ # @return [self] Returns the object itself after refreshing, allowing
95
+ # method chaining.
96
+ def refresh
97
+ refresh!
98
+ self
99
+ end
100
+
101
+ def to_h
102
+ # Use self.class.fields to efficiently generate a hash
103
+ # of all the fields for this object
104
+ self.class.fields.inject({}) do |hsh, field|
105
+ val = send(field)
106
+ prepared = to_redis(val)
107
+ Familia.ld " [to_h] field: #{field} val: #{val.class} prepared: #{prepared.class}"
108
+ hsh[field] = prepared
109
+ hsh
110
+ end
111
+ end
112
+
113
+ def to_a
114
+ self.class.fields.map do |field|
115
+ val = send(field)
116
+ prepared = to_redis(val)
117
+ Familia.ld " [to_a] field: #{field} val: #{val.class} prepared: #{prepared.class}"
118
+ prepared
119
+ end
120
+ end
121
+
122
+ # The to_redis method in Familia::Redistype and Familia::Horreum serve
123
+ # similar purposes but have some key differences in their implementation:
124
+ #
125
+ # Similarities:
126
+ # - Both methods aim to serialize various data types for Redis storage
127
+ # - Both handle basic data types like String, Symbol, and Numeric
128
+ # - Both have provisions for custom serialization methods
129
+ #
130
+ # Differences:
131
+ # - Familia::Redistype uses the opts[:class] for type hints
132
+ # - Familia::Horreum had more explicit type checking and conversion
133
+ # - Familia::Redistype includes more extensive debug tracing
134
+ #
135
+ # The centralized Familia.distinguisher method accommodates both approaches
136
+ # by:
137
+ # 1. Handling a wide range of data types, including those from both
138
+ # implementations
139
+ # 2. Providing a 'strict_values' option for flexible type handling
140
+ # 3. Supporting custom serialization through a dump_method
141
+ # 4. Including debug tracing similar to Familia::Redistype
142
+ #
143
+ # By using Familia.distinguisher, we achieve more consistent behavior
144
+ # across different parts of the library while maintaining the flexibility
145
+ # to handle various data types and custom serialization needs. This
146
+ # centralization also makes it easier to extend or modify serialization
147
+ # behavior in the future.
148
+ #
149
+ def to_redis(val)
150
+ prepared = Familia.distinguisher(val, false)
151
+
152
+ if prepared.nil? && val.respond_to?(dump_method)
153
+ prepared = val.send(dump_method)
154
+ end
155
+
156
+ if prepared.nil?
157
+ Familia.ld "[#{self.class}#to_redis] nil returned for #{self.class}##{name}"
158
+ end
159
+
160
+ prepared
161
+ end
162
+
163
+ def update_expiration(ttl = nil)
164
+ ttl ||= opts[:ttl]
165
+ return if ttl.to_i.zero? # nil will be zero
166
+
167
+ Familia.ld "#{rediskey} to #{ttl}"
168
+ expire ttl.to_i
169
+ end
170
+ end
171
+ # End of Serialization module
172
+
173
+ include Serialization # these become Horreum instance methods
174
+ end
175
+ end
176
+
177
+ __END__
178
+
179
+ # Consider adding a retry mechanism for the refresh operation
180
+ # if it fails to fetch the expected data:
181
+ def refresh_with_retry(max_attempts = 3)
182
+ attempts = 0
183
+ base = 2
184
+ while attempts < max_attempts
185
+ refresh!
186
+ return if name == "Jane Doe" # Or whatever condition indicates a successful refresh
187
+ attempts += 1
188
+
189
+ sleep_time = 0.1 * (base ** attempts)
190
+ sleep(sleep_time) # Exponential backoff
191
+ end
192
+ raise "Failed to refresh after #{max_attempts} attempts"
193
+ end
@@ -0,0 +1,63 @@
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
+ # Settings - Module containing settings for Familia::Horreum (InstanceMethods)
12
+ #
13
+ module Settings
14
+ attr_writer :dump_method, :load_method, :suffix
15
+
16
+ def opts
17
+ @opts ||= {}
18
+ @opts
19
+ end
20
+
21
+ def redisdetails
22
+ {
23
+ uri: self.class.uri,
24
+ db: self.class.db,
25
+ key: rediskey,
26
+ type: redistype,
27
+ ttl: ttl,
28
+ realttl: realttl
29
+ }
30
+ end
31
+
32
+ def ttl=(v)
33
+ @ttl = v.to_i
34
+ end
35
+
36
+ def ttl
37
+ @ttl || self.class.ttl
38
+ end
39
+
40
+ def db=(v)
41
+ @db = v.to_i
42
+ end
43
+
44
+ def db
45
+ @db || self.class.db
46
+ end
47
+
48
+ def suffix
49
+ @suffix || self.class.suffix
50
+ end
51
+
52
+ def dump_method
53
+ @dump_method || self.class.dump_method
54
+ end
55
+
56
+ def load_method
57
+ @load_method || self.class.load_method
58
+ end
59
+ end
60
+
61
+ include Settings # these become Horreum instance methods
62
+ end
63
+ end
@@ -0,0 +1,44 @@
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
+ # Utils - Module containing utility methods for Familia::Horreum (InstanceMethods)
12
+ #
13
+ module Utils
14
+
15
+ def redisuri(suffix = nil)
16
+ u = Familia.redisuri(self.class.uri) # returns URI::Redis
17
+ u.db ||= self.class.db.to_s # TODO: revisit logic (should the horrerum instance know its uri?)
18
+ u.key = rediskey(suffix)
19
+ u
20
+ end
21
+
22
+ # +suffix+ is the value to be used at the end of the redis key
23
+ # (e.g. `customer:customer_id:scores` would have `scores` as the suffix
24
+ # and `customer_id` would have been the identifier in that case).
25
+ #
26
+ # identifier is the value that distinguishes this object from others.
27
+ # Whether this is a Horreum or RedisType object, the value is taken
28
+ # from the `identifier` method).
29
+ #
30
+ def rediskey(suffix = nil, ignored = nil)
31
+ Familia.ld "[#rediskey] #{identifier} for #{self.class}"
32
+ raise Familia::NoIdentifier, "No identifier for #{self.class}" if identifier.to_s.empty?
33
+ suffix ||= self.suffix # use the instance method to get the default suffix
34
+ self.class.rediskey identifier, suffix
35
+ end
36
+
37
+ def join(*args)
38
+ Familia.join(args.map { |field| send(field) })
39
+ end
40
+ end
41
+
42
+ include Utils # these become Horreum instance methods
43
+ end
44
+ end
@@ -0,0 +1,248 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Familia
4
+ #
5
+ # Horreum: A module for managing Redis-based object storage and relationships
6
+ #
7
+ # Key features:
8
+ # * Provides instance-level access to a single hash in Redis
9
+ # * Includes Familia for class/module level access to Redis types and operations
10
+ # * Uses 'hashkey' to define a Redis hash referred to as "object"
11
+ # * Applies a default expiry (5 years) to all keys
12
+ #
13
+ # Metaprogramming:
14
+ # * The class << self block defines class-level behavior
15
+ # * The `inherited` method extends ClassMethods to subclasses like
16
+ # `MyModel` in the example below
17
+ #
18
+ # Usage:
19
+ # class MyModel < Familia::Horreum
20
+ # field :name
21
+ # field :email
22
+ # end
23
+ #
24
+ class Horreum
25
+ include Familia::Base
26
+
27
+ # == Singleton Class Context
28
+ #
29
+ # The code within this block operates on the singleton class (also known as
30
+ # eigenclass or metaclass) of the current class. This means:
31
+ #
32
+ # 1. Methods defined here become class methods, not instance methods.
33
+ # 2. Constants and variables set here belong to the class, not instances.
34
+ # 3. This is the place to define class-level behavior and properties.
35
+ #
36
+ # Use this context for:
37
+ # * Defining class methods
38
+ # * Setting class-level configurations
39
+ # * Creating factory methods
40
+ # * Establishing relationships with other classes
41
+ #
42
+ # Example:
43
+ # class MyClass
44
+ # class << self
45
+ # def class_method
46
+ # puts "This is a class method"
47
+ # end
48
+ # end
49
+ # end
50
+ #
51
+ # MyClass.class_method # => "This is a class method"
52
+ #
53
+ # Note: Changes made here affect the class itself and all future instances,
54
+ # but not existing instances of the class.
55
+ #
56
+ class << self
57
+ # Extends ClassMethods to subclasses and tracks Familia members
58
+ def inherited(member)
59
+ Familia.trace :INHERITED, nil, "Inherited by #{member}", caller if Familia.debug?
60
+ member.extend(ClassMethods)
61
+ member.extend(Features)
62
+
63
+ # Tracks all the classes/modules that include Familia. It's
64
+ # 10pm, do you know where you Familia members are?
65
+ Familia.members << member
66
+ super
67
+ end
68
+ end
69
+
70
+ # Instance initialization
71
+ # This method sets up the object's state, including Redis-related data
72
+ def initialize(*args, **kwargs)
73
+ Familia.ld "[Horreum] Initializing #{self.class}"
74
+ initialize_relatives
75
+
76
+ # If there are positional arguments, they should be the field
77
+ # values in the order they were defined in the implementing class.
78
+ #
79
+ # Handle keyword arguments
80
+ # Fields is a known quantity, so we iterate over it rather than kwargs
81
+ # to ensure that we only set fields that are defined in the class. And
82
+ # to avoid runaways.
83
+ if args.any?
84
+ initialize_with_positional_args(*args)
85
+ elsif kwargs.any?
86
+ initialize_with_keyword_args(**kwargs)
87
+ else
88
+ Familia.ld "[Horreum] #{self.class} initialized with no arguments"
89
+ # If there are no arguments, we need to set the default values
90
+ # for the fields. This is done in the order they were defined.
91
+ # self.class.fields.each do |field|
92
+ # default = self.class.defaults[field]
93
+ # send(:"#{field}=", default) if default
94
+ # end
95
+ end
96
+
97
+ # Automatically add a 'key' field if it's not already defined
98
+ # This ensures that every object has a unique identifier
99
+ unless self.class.fields.include?(:key)
100
+ # Define the 'key' field for this class
101
+ # This approach allows flexibility in how identifiers are generated
102
+ # while ensuring each object has a consistent way to be referenced
103
+ self.class.field :key # , default: -> { identifier }
104
+ end
105
+
106
+ # Implementing classes can define an init method to do any
107
+ # additional initialization. Notice that this is called
108
+ # after the fields are set.
109
+ init if respond_to?(:init)
110
+ end
111
+
112
+ # Sets up related Redis objects for the instance
113
+ # This method is crucial for establishing Redis-based relationships
114
+ #
115
+ # This needs to be called in the initialize method.
116
+ #
117
+ def initialize_relatives
118
+ # Generate instances of each RedisType. These need to be
119
+ # unique for each instance of this class so they can piggyback
120
+ # on the specifc index of this instance.
121
+ #
122
+ # i.e.
123
+ # familia_object.rediskey == v1:bone:INDEXVALUE:object
124
+ # familia_object.redis_type.rediskey == v1:bone:INDEXVALUE:name
125
+ #
126
+ # See RedisType.install_redis_type
127
+ self.class.redis_types.each_pair do |name, redis_type_definition|
128
+ klass = redis_type_definition.klass
129
+ opts = redis_type_definition.opts
130
+ Familia.ld "[#{self.class}] initialize_relatives #{name} => #{klass} #{opts.keys}"
131
+
132
+ # As a subclass of Familia::Horreum, we add ourselves as the parent
133
+ # automatically. This is what determines the rediskey for RedisType
134
+ # instance and which redis connection.
135
+ #
136
+ # e.g. If the parent's rediskey is `customer:customer_id:object`
137
+ # then the rediskey for this RedisType instance will be
138
+ # `customer:customer_id:name`.
139
+ #
140
+ opts[:parent] = self # unless opts.key(:parent)
141
+
142
+ # Instantiate the RedisType object and below we store it in
143
+ # an instance variable.
144
+ redis_type = klass.new name, opts
145
+
146
+ # Freezes the redis_type, making it immutable.
147
+ # This ensures the object's state remains consistent and prevents any modifications,
148
+ # safeguarding its integrity and making it thread-safe.
149
+ # Any attempts to change the object after this will raise a FrozenError.
150
+ redis_type.freeze
151
+
152
+ # e.g. customer.name #=> `#<Familia::HashKey:0x0000...>`
153
+ instance_variable_set :"@#{name}", redis_type
154
+ end
155
+ end
156
+
157
+ # Initializes the object with positional arguments.
158
+ # Maps each argument to a corresponding field in the order they are defined.
159
+ #
160
+ # @param args [Array] List of values to be assigned to fields
161
+ # @return [Array<Symbol>] List of field names that were successfully updated
162
+ # (i.e., had non-nil values assigned)
163
+ # @private
164
+ def initialize_with_positional_args(*args)
165
+ Familia.trace :INITIALIZE_ARGS, redis, args, caller(1..1) if Familia.debug?
166
+ self.class.fields.zip(args).filter_map do |field, value|
167
+ if value
168
+ send(:"#{field}=", value)
169
+ field.to_sym
170
+ end
171
+ end
172
+ end
173
+ private :initialize_with_positional_args
174
+
175
+ # Initializes the object with keyword arguments.
176
+ # Assigns values to fields based on the provided hash of field names and values.
177
+ # Handles both symbol and string keys to accommodate different sources of data.
178
+ #
179
+ # @param fields [Hash] Hash of field names (as symbols or strings) and their values
180
+ # @return [Array<Symbol>] List of field names that were successfully updated
181
+ # (i.e., had non-nil values assigned)
182
+ # @private
183
+ def initialize_with_keyword_args(**fields)
184
+ Familia.trace :INITIALIZE_KWARGS, redis, fields.keys, caller(1..1) if Familia.debug?
185
+ self.class.fields.filter_map do |field|
186
+ # Redis will give us field names as strings back, but internally
187
+ # we use symbols. So we check for both.
188
+ value = fields[field.to_sym] || fields[field.to_s]
189
+ if value
190
+ send(:"#{field}=", value)
191
+ field.to_sym
192
+ end
193
+ end
194
+ end
195
+ private :initialize_with_keyword_args
196
+
197
+ # A thin wrapper around the private initialize method that accepts a field
198
+ # hash and refreshes the existing object.
199
+ #
200
+ # This method is part of horreum.rb rather than serialization.rb because it
201
+ # operates solely on the provided values and doesn't query Redis or other
202
+ # external sources. That's why it's called "optimistic" refresh: it assumes
203
+ # the provided values are correct and updates the object accordingly.
204
+ #
205
+ # @see #refresh!
206
+ #
207
+ # @param fields [Hash] A hash of field names and their new values to update
208
+ # the object with.
209
+ # @return [Array] The list of field names that were updated.
210
+ def optimistic_refresh(**fields)
211
+ Familia.ld "[optimistic_refresh] #{self.class} #{rediskey} #{fields.keys}"
212
+ initialize_with_keyword_args(**fields)
213
+ end
214
+
215
+ # Determines the unique identifier for the instance
216
+ # This method is used to generate Redis keys for the object
217
+ def identifier
218
+ definition = self.class.identifier # e.g.
219
+ # When definition is a symbol or string, assume it's an instance method
220
+ # to call on the object to get the unique identifier. When it's a callable
221
+ # object, call it with the object as the argument. When it's an array,
222
+ # call each method in turn and join the results. When it's nil, raise
223
+ # an error
224
+ unique_id = case definition
225
+ when Symbol, String
226
+ send(definition)
227
+ when Proc
228
+ definition.call(self)
229
+ when Array
230
+ Familia.join(definition.map { |method| send(method) })
231
+ else
232
+ raise Problem, "Invalid identifier definition: #{definition.inspect}"
233
+ end
234
+
235
+ # If the unique_id is nil, raise an error
236
+ raise Problem, "Identifier is nil for #{self.class}" if unique_id.nil?
237
+ raise Problem, 'Identifier is empty' if unique_id.empty?
238
+
239
+ unique_id
240
+ end
241
+ end
242
+ end
243
+
244
+ require_relative 'horreum/class_methods'
245
+ require_relative 'horreum/commands'
246
+ require_relative 'horreum/serialization'
247
+ require_relative 'horreum/settings'
248
+ require_relative 'horreum/utils'