familia 2.0.0.pre18 → 2.0.0.pre19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.rst +58 -6
  3. data/CLAUDE.md +34 -9
  4. data/Gemfile +2 -2
  5. data/Gemfile.lock +9 -47
  6. data/README.md +39 -0
  7. data/changelog.d/20251011_012003_delano_159_datatype_transaction_pipeline_support.rst +91 -0
  8. data/changelog.d/20251011_203905_delano_next.rst +30 -0
  9. data/changelog.d/20251011_212633_delano_next.rst +13 -0
  10. data/changelog.d/20251011_221253_delano_next.rst +26 -0
  11. data/docs/guides/feature-expiration.md +18 -18
  12. data/docs/migrating/v2.0.0-pre19.md +197 -0
  13. data/examples/datatype_standalone.rb +281 -0
  14. data/lib/familia/connection/behavior.rb +252 -0
  15. data/lib/familia/connection/handlers.rb +95 -0
  16. data/lib/familia/connection/operation_core.rb +1 -1
  17. data/lib/familia/connection/{pipeline_core.rb → pipelined_core.rb} +2 -2
  18. data/lib/familia/connection/transaction_core.rb +7 -9
  19. data/lib/familia/connection.rb +3 -2
  20. data/lib/familia/data_type/connection.rb +151 -7
  21. data/lib/familia/data_type/database_commands.rb +7 -4
  22. data/lib/familia/data_type/serialization.rb +4 -0
  23. data/lib/familia/data_type/types/hashkey.rb +1 -1
  24. data/lib/familia/errors.rb +51 -14
  25. data/lib/familia/features/expiration/extensions.rb +8 -10
  26. data/lib/familia/features/expiration.rb +19 -19
  27. data/lib/familia/features/relationships/indexing/multi_index_generators.rb +39 -38
  28. data/lib/familia/features/relationships/indexing/unique_index_generators.rb +115 -43
  29. data/lib/familia/features/relationships/indexing.rb +37 -42
  30. data/lib/familia/features/relationships/indexing_relationship.rb +14 -4
  31. data/lib/familia/field_type.rb +2 -1
  32. data/lib/familia/horreum/connection.rb +11 -35
  33. data/lib/familia/horreum/database_commands.rb +129 -10
  34. data/lib/familia/horreum/definition.rb +2 -1
  35. data/lib/familia/horreum/management.rb +21 -15
  36. data/lib/familia/horreum/persistence.rb +190 -66
  37. data/lib/familia/horreum/serialization.rb +3 -0
  38. data/lib/familia/horreum/utils.rb +0 -8
  39. data/lib/familia/horreum.rb +31 -12
  40. data/lib/familia/logging.rb +2 -5
  41. data/lib/familia/settings.rb +7 -7
  42. data/lib/familia/version.rb +1 -1
  43. data/lib/middleware/database_logger.rb +76 -5
  44. data/try/edge_cases/string_coercion_try.rb +4 -4
  45. data/try/features/expiration/expiration_try.rb +1 -1
  46. data/try/features/relationships/indexing_try.rb +28 -4
  47. data/try/features/relationships/relationships_api_changes_try.rb +4 -4
  48. data/try/integration/connection/fiber_context_preservation_try.rb +3 -3
  49. data/try/integration/connection/operation_mode_guards_try.rb +1 -1
  50. data/try/integration/connection/pipeline_fallback_integration_try.rb +12 -12
  51. data/try/integration/create_method_try.rb +22 -22
  52. data/try/integration/data_types/datatype_pipelines_try.rb +104 -0
  53. data/try/integration/data_types/datatype_transactions_try.rb +247 -0
  54. data/try/integration/models/customer_safe_dump_try.rb +5 -1
  55. data/try/integration/models/familia_object_try.rb +1 -1
  56. data/try/integration/persistence_operations_try.rb +162 -10
  57. data/try/unit/data_types/boolean_try.rb +1 -1
  58. data/try/unit/data_types/string_try.rb +1 -1
  59. data/try/unit/horreum/auto_indexing_on_save_try.rb +32 -16
  60. data/try/unit/horreum/automatic_index_validation_try.rb +253 -0
  61. data/try/unit/horreum/base_try.rb +1 -1
  62. data/try/unit/horreum/class_methods_try.rb +2 -2
  63. data/try/unit/horreum/initialization_try.rb +1 -1
  64. data/try/unit/horreum/relations_try.rb +4 -4
  65. data/try/unit/horreum/serialization_try.rb +2 -2
  66. data/try/unit/horreum/unique_index_edge_cases_try.rb +376 -0
  67. data/try/unit/horreum/unique_index_guard_validation_try.rb +281 -0
  68. metadata +14 -2
@@ -1,19 +1,52 @@
1
1
  # lib/familia/errors.rb
2
2
  #
3
3
  module Familia
4
+ # Base exception class for all Familia errors
4
5
  class Problem < RuntimeError; end
5
- class NoIdentifier < Problem; end
6
- class NonUniqueKey < Problem; end
7
6
 
8
- class FieldTypeError < Problem; end
9
- class AutoloadError < Problem; end
7
+ # Base exception class for Redis/persistence-related errors
8
+ class PersistenceError < Problem; end
10
9
 
11
- class SerializerError < Problem; end
10
+ # Base exception class for Horreum models
11
+ class HorreumError < Problem; end
12
12
 
13
- # Raised when attempting to start transactions or pipelines on connection types that don't support them
14
- class OperationModeError < Problem; end
13
+ # Raised when an object creation fails (e.g. the identifier
14
+ # is already in use)
15
+ class CreationError < HorreumError; end
15
16
 
16
- class NotDistinguishableError < Problem
17
+ # Raised when an object lacks a required identifier
18
+ class NoIdentifier < HorreumError; end
19
+
20
+ # Raised when a key is expected to be unique but isn't
21
+ class NonUniqueKey < PersistenceError; end
22
+
23
+ # Raised when watch failed (e.g. key was modified), typically
24
+ # retry
25
+ class OptimisticLockError < PersistenceError; end
26
+
27
+ # Raised when a field type is invalid or unexpected
28
+ class FieldTypeError < HorreumError; end
29
+
30
+ # Raised when autoloading fails
31
+ class AutoloadError < HorreumError; end
32
+
33
+ # Raised when serialization or deserialization fails
34
+ class SerializerError < HorreumError; end
35
+
36
+ # Raised when attempting to start transactions or pipelines on
37
+ # connection types that don't support them
38
+ class OperationModeError < PersistenceError; end
39
+
40
+ # Raised when attempting to call a major method like save when
41
+ # inside a transaction or pipeline
42
+ class NestedTransactionError < OperationModeError; end
43
+
44
+ # Raised when attempting to reference a field that doesn't exist
45
+ class UnknownFieldError < HorreumError; end
46
+
47
+ # Raised when a value cannot be converted to a distinguishable
48
+ # string representation
49
+ class NotDistinguishableError < HorreumError
17
50
  attr_reader :value
18
51
 
19
52
  def initialize(value)
@@ -26,7 +59,8 @@ module Familia
26
59
  end
27
60
  end
28
61
 
29
- class NotConnected < Problem
62
+ # Raised when no connection is available for a given URI
63
+ class NotConnected < PersistenceError
30
64
  attr_reader :uri
31
65
 
32
66
  def initialize(uri)
@@ -39,13 +73,15 @@ module Familia
39
73
  end
40
74
  end
41
75
 
42
- # UnsortedSet Familia.connection_provider or use middleware to provide connections.
43
- class NoConnectionAvailable < Problem; end
76
+ # UnsortedSet Familia.connection_provider or use middleware
77
+ # to provide connections.
78
+ class NoConnectionAvailable < PersistenceError; end
44
79
 
45
80
  # Raised when a load method fails to find the requested object
46
- class NotFound < Problem; end
81
+ class NotFound < PersistenceError; end
47
82
 
48
- # Raised when attempting to refresh an object whose key doesn't exist in the database
83
+ # Raised when attempting to refresh an object whose key
84
+ # doesn't exist in the database
49
85
  class KeyNotFoundError < NonUniqueKey
50
86
  attr_reader :key
51
87
 
@@ -59,7 +95,8 @@ module Familia
59
95
  end
60
96
  end
61
97
 
62
- # Raised when attempting to create an object that already exists in the database
98
+ # Raised when attempting to create an object that already
99
+ # exists in the database
63
100
  class RecordExistsError < NonUniqueKey
64
101
  attr_reader :key
65
102
 
@@ -10,25 +10,23 @@ module Familia
10
10
  # with the :expiration feature's implementation.
11
11
  #
12
12
  # This is a no-op implementation that gets overridden by the :expiration
13
- # feature. It accepts an optional default_expiration parameter to maintain
14
- # interface compatibility with the overriding implementations.
13
+ # feature when it is enabled. This allows for calling this method on any
14
+ # horreum model regardless of the feature status. It accepts an optional
15
+ # expiration parameter to maintain interface compatibility with
16
+ # the overriding implementations.
15
17
  #
16
- # @param default_expiration [Numeric, nil] Time To Live in seconds
18
+ # @param expiration [Numeric, nil] Time To Live in seconds
17
19
  # @return [nil] Always returns nil for the base implementation
18
20
  #
19
21
  # @note This is a no-op implementation. Classes that need expiration
20
22
  # functionality should include the :expiration feature.
21
23
  #
22
- # @example Enable expiration feature
23
- # class MyModel < Familia::Horreum
24
- # feature :expiration
25
- # default_expiration 1.hour
26
- # end
24
+ # @example MyModel.new.update_expiration(expiration: 3600) # => nothing happens
27
25
  #
28
- def update_expiration(default_expiration: nil)
26
+ def update_expiration(expiration: nil)
29
27
  Familia.ld <<~LOG
30
28
  [update_expiration] Expiration feature not enabled for #{self.class}.
31
- Key: #{dbkey} Arg: #{default_expiration} (caller: #{caller(1..1)})
29
+ Key: #{dbkey} Arg: #{expiration} (caller: #{caller(1..1)})
32
30
  LOG
33
31
  nil
34
32
  end
@@ -36,7 +36,7 @@ module Familia
36
36
  # session.ttl # => 1799
37
37
  #
38
38
  # # UnsortedSet custom expiration for new objects
39
- # session.update_expiration(default_expiration: 2.hours)
39
+ # session.update_expiration(expiration: 2.hours)
40
40
  #
41
41
  # Class-Level Configuration:
42
42
  #
@@ -89,7 +89,7 @@ module Familia
89
89
  #
90
90
  # Setting expiration to 0 (zero) disables TTL, making data persist indefinitely:
91
91
  #
92
- # session.update_expiration(default_expiration: 0) # No expiration
92
+ # session.update_expiration(expiration: 0) # No expiration
93
93
  #
94
94
  # TTL Querying:
95
95
  #
@@ -115,7 +115,7 @@ module Familia
115
115
  # when 'free'
116
116
  # update_expiration(1.hour)
117
117
  # else
118
- # update_expiration(default_expiration)
118
+ # update_expiration(expiration)
119
119
  # end
120
120
  # end
121
121
  # end
@@ -135,10 +135,10 @@ module Familia
135
135
  #
136
136
  # The feature validates expiration values and raises descriptive errors:
137
137
  #
138
- # session.update_expiration(default_expiration: "invalid")
138
+ # session.update_expiration(expiration: "invalid")
139
139
  # # => Familia::Problem: Default expiration must be a number
140
140
  #
141
- # session.update_expiration(default_expiration: -1)
141
+ # session.update_expiration(expiration: -1)
142
142
  # # => Familia::Problem: Default expiration must be non-negative
143
143
  #
144
144
  # Performance Considerations:
@@ -226,20 +226,20 @@ module Familia
226
226
  # after which it will be automatically removed. The method also handles
227
227
  # cascading expiration to related data structures when applicable.
228
228
  #
229
- # @param default_expiration [Numeric, nil] The Time To Live in seconds. If nil,
229
+ # @param expiration [Numeric, nil] The Time To Live in seconds. If nil,
230
230
  # the default TTL will be used.
231
231
  #
232
232
  # @return [Boolean] Returns true if the expiration was set successfully,
233
233
  # false otherwise.
234
234
  #
235
235
  # @example Setting an expiration of one day
236
- # object.update_expiration(default_expiration: 86400)
236
+ # object.update_expiration(expiration: 86400)
237
237
  #
238
238
  # @example Using default expiration
239
239
  # object.update_expiration # Uses class default_expiration
240
240
  #
241
241
  # @example Removing expiration (persist indefinitely)
242
- # object.update_expiration(default_expiration: 0)
242
+ # object.update_expiration(expiration: 0)
243
243
  #
244
244
  # @note If default expiration is set to zero, the expiration will be removed,
245
245
  # making the data persist indefinitely.
@@ -247,8 +247,8 @@ module Familia
247
247
  # @raise [Familia::Problem] Raises an error if the default expiration is not
248
248
  # a non-negative number.
249
249
  #
250
- def update_expiration(default_expiration: nil)
251
- default_expiration ||= self.default_expiration
250
+ def update_expiration(expiration: nil)
251
+ expiration ||= default_expiration
252
252
 
253
253
  # Handle cascading expiration to related data structures
254
254
  if self.class.relations?
@@ -258,8 +258,8 @@ module Familia
258
258
  next if definition.opts[:default_expiration].nil?
259
259
 
260
260
  obj = send(name)
261
- Familia.ld "[update_expiration] Updating expiration for #{name} (#{obj.dbkey}) to #{default_expiration}"
262
- obj.update_expiration(default_expiration: default_expiration)
261
+ Familia.ld "[update_expiration] Updating expiration for #{name} (#{obj.dbkey}) to #{expiration}"
262
+ obj.update_expiration(expiration: expiration)
263
263
  end
264
264
  end
265
265
 
@@ -267,29 +267,29 @@ module Familia
267
267
  # It's important to raise exceptions here and not just log warnings. We
268
268
  # don't want to silently fail at setting expirations and cause data
269
269
  # retention issues (e.g. not removed in a timely fashion).
270
- unless default_expiration.is_a?(Numeric)
270
+ unless expiration.is_a?(Numeric)
271
271
  raise Familia::Problem,
272
- "Default expiration must be a number (#{default_expiration.class} given for #{self.class})"
272
+ "Default expiration must be a number (#{expiration.class} given for #{self.class})"
273
273
  end
274
274
 
275
- unless default_expiration >= 0
275
+ unless expiration >= 0
276
276
  raise Familia::Problem,
277
- "Default expiration must be non-negative (#{default_expiration} given for #{self.class})"
277
+ "Default expiration must be non-negative (#{expiration} given for #{self.class})"
278
278
  end
279
279
 
280
280
  # If zero, simply skip setting an expiry for this key. If we were to set
281
281
  # 0, Valkey/Redis would drop the key immediately.
282
- if default_expiration.zero?
282
+ if expiration.zero?
283
283
  Familia.ld "[update_expiration] No expiration for #{self.class} (#{dbkey})"
284
284
  return true
285
285
  end
286
286
 
287
- Familia.ld "[update_expiration] Expires #{dbkey} in #{default_expiration} seconds"
287
+ Familia.ld "[update_expiration] Expires #{dbkey} in #{expiration} seconds"
288
288
 
289
289
  # The Valkey/Redis' EXPIRE command returns 1 if the timeout was set, 0
290
290
  # if key does not exist or the timeout could not be set. Via redis-rb,
291
291
  # it's a boolean.
292
- expire(default_expiration)
292
+ expire(expiration)
293
293
  end
294
294
 
295
295
  # Get the remaining time to live for this object's data
@@ -32,29 +32,30 @@ module Familia
32
32
  # @param indexed_class [Class] The class being indexed (e.g., Employee)
33
33
  # @param field [Symbol] The field to index
34
34
  # @param index_name [Symbol] Name of the index
35
- # @param within [Class, Symbol] Parent class for instance-scoped index (required)
35
+ # @param within [Class, Symbol] Scope class for instance-scoped index (required)
36
36
  # @param query [Boolean] Whether to generate query methods
37
37
  def setup(indexed_class:, field:, index_name:, within:, query:)
38
- # Multi-index always requires a parent context
39
- target_class = within
40
- resolved_class = Familia.resolve_class(target_class)
38
+ # Multi-index always requires a scope context
39
+ scope_class = within
40
+ resolved_class = Familia.resolve_class(scope_class)
41
41
 
42
42
  # Store metadata for this indexing relationship
43
43
  indexed_class.indexing_relationships << IndexingRelationship.new(
44
44
  field: field,
45
- target_class: target_class,
45
+ scope_class: scope_class,
46
+ within: within,
46
47
  index_name: index_name,
47
48
  query: query,
48
49
  cardinality: :multi,
49
50
  )
50
51
 
51
52
  # Always generate the factory method - required by mutation methods
52
- if target_class.is_a?(Class)
53
+ if scope_class.is_a?(Class)
53
54
  generate_factory_method(resolved_class, index_name)
54
55
  end
55
56
 
56
- # Generate query methods on the parent class (optional)
57
- if query && target_class.is_a?(Class)
57
+ # Generate query methods on the scope class (optional)
58
+ if query && scope_class.is_a?(Class)
58
59
  generate_query_methods_destination(indexed_class, field, resolved_class, index_name)
59
60
  end
60
61
 
@@ -62,17 +63,17 @@ module Familia
62
63
  generate_mutation_methods_self(indexed_class, field, resolved_class, index_name)
63
64
  end
64
65
 
65
- # Generates the factory method ON THE PARENT CLASS (Company when within: Company):
66
+ # Generates the factory method ON THE SCOPE CLASS (Company when within: Company):
66
67
  # - company.index_name_for(field_value) - DataType factory (always needed)
67
68
  #
68
69
  # This method is required by mutation methods even when query: false
69
70
  #
70
- # @param target_class [Class] The parent class (e.g., Company)
71
+ # @param scope_class [Class] The scope class providing uniqueness context (e.g., Company)
71
72
  # @param index_name [Symbol] Name of the index (e.g., :dept_index)
72
- def generate_factory_method(target_class, index_name)
73
- actual_target_class = Familia.resolve_class(target_class)
73
+ def generate_factory_method(scope_class, index_name)
74
+ actual_scope_class = Familia.resolve_class(scope_class)
74
75
 
75
- actual_target_class.class_eval do
76
+ actual_scope_class.class_eval do
76
77
  # Helper method to get index set for a specific field value
77
78
  # This acts as a factory for field-value-specific DataTypes
78
79
  define_method(:"#{index_name}_for") do |field_value|
@@ -83,21 +84,21 @@ module Familia
83
84
  end
84
85
  end
85
86
 
86
- # Generates query methods ON THE PARENT CLASS (Company when within: Company):
87
+ # Generates query methods ON THE SCOPE CLASS (Company when within: Company):
87
88
  # - company.sample_from_department(dept, count=1) - random sampling
88
89
  # - company.find_all_by_department(dept) - all objects
89
90
  # - company.rebuild_dept_index - rebuild index
90
91
  #
91
92
  # @param indexed_class [Class] The class being indexed (e.g., Employee)
92
93
  # @param field [Symbol] The field to index (e.g., :department)
93
- # @param target_class [Class] The parent class (e.g., Company)
94
+ # @param scope_class [Class] The scope class providing uniqueness context (e.g., Company)
94
95
  # @param index_name [Symbol] Name of the index (e.g., :dept_index)
95
- def generate_query_methods_destination(indexed_class, field, target_class, index_name)
96
- # Resolve target class using Familia pattern
97
- actual_target_class = Familia.resolve_class(target_class)
96
+ def generate_query_methods_destination(indexed_class, field, scope_class, index_name)
97
+ # Resolve scope class using Familia pattern
98
+ actual_scope_class = Familia.resolve_class(scope_class)
98
99
 
99
100
  # Generate instance sampling method (e.g., company.sample_from_department)
100
- actual_target_class.class_eval do
101
+ actual_scope_class.class_eval do
101
102
 
102
103
  define_method(:"sample_from_#{field}") do |field_value, count = 1|
103
104
  index_set = send("#{index_name}_for", field_value) # i.e. UnsortedSet
@@ -133,62 +134,62 @@ module Familia
133
134
  #
134
135
  # @param indexed_class [Class] The class being indexed (e.g., Employee)
135
136
  # @param field [Symbol] The field to index (e.g., :department)
136
- # @param target_class [Class] The parent class (e.g., Company)
137
+ # @param scope_class [Class] The scope class providing uniqueness context (e.g., Company)
137
138
  # @param index_name [Symbol] Name of the index (e.g., :dept_index)
138
- def generate_mutation_methods_self(indexed_class, field, target_class, index_name)
139
- target_class_config = target_class.config_name
139
+ def generate_mutation_methods_self(indexed_class, field, scope_class, index_name)
140
+ scope_class_config = scope_class.config_name
140
141
  indexed_class.class_eval do
141
- method_name = :"add_to_#{target_class_config}_#{index_name}"
142
+ method_name = :"add_to_#{scope_class_config}_#{index_name}"
142
143
  Familia.ld("[MultiIndexGenerators] #{name} method #{method_name}")
143
144
 
144
- define_method(method_name) do |target_instance|
145
- return unless target_instance
145
+ define_method(method_name) do |scope_instance|
146
+ return unless scope_instance
146
147
 
147
148
  field_value = send(field)
148
149
  return unless field_value
149
150
 
150
- # Use helper method on target instance instead of manual instantiation
151
- index_set = target_instance.send("#{index_name}_for", field_value)
151
+ # Use helper method on scope instance instead of manual instantiation
152
+ index_set = scope_instance.send("#{index_name}_for", field_value)
152
153
 
153
154
  # Use UnsortedSet DataType method (no scoring)
154
155
  index_set.add(identifier)
155
156
  end
156
157
 
157
- method_name = :"remove_from_#{target_class_config}_#{index_name}"
158
+ method_name = :"remove_from_#{scope_class_config}_#{index_name}"
158
159
  Familia.ld("[MultiIndexGenerators] #{name} method #{method_name}")
159
160
 
160
- define_method(method_name) do |target_instance|
161
- return unless target_instance
161
+ define_method(method_name) do |scope_instance|
162
+ return unless scope_instance
162
163
 
163
164
  field_value = send(field)
164
165
  return unless field_value
165
166
 
166
- # Use helper method on target instance instead of manual instantiation
167
- index_set = target_instance.send("#{index_name}_for", field_value)
167
+ # Use helper method on scope instance instead of manual instantiation
168
+ index_set = scope_instance.send("#{index_name}_for", field_value)
168
169
 
169
170
  # Remove using UnsortedSet DataType method
170
171
  index_set.remove(identifier)
171
172
  end
172
173
 
173
- method_name = :"update_in_#{target_class_config}_#{index_name}"
174
+ method_name = :"update_in_#{scope_class_config}_#{index_name}"
174
175
  Familia.ld("[MultiIndexGenerators] #{name} method #{method_name}")
175
176
 
176
- define_method(method_name) do |target_instance, old_field_value = nil|
177
- return unless target_instance
177
+ define_method(method_name) do |scope_instance, old_field_value = nil|
178
+ return unless scope_instance
178
179
 
179
180
  new_field_value = send(field)
180
181
 
181
182
  # Use Familia's transaction method for atomicity with DataType abstraction
182
- target_instance.transaction do |_tx|
183
+ scope_instance.transaction do |_tx|
183
184
  # Remove from old index if provided - use helper method
184
185
  if old_field_value
185
- old_index_set = target_instance.send("#{index_name}_for", old_field_value)
186
+ old_index_set = scope_instance.send("#{index_name}_for", old_field_value)
186
187
  old_index_set.remove(identifier)
187
188
  end
188
189
 
189
190
  # Add to new index if present - use helper method
190
191
  if new_field_value
191
- new_index_set = target_instance.send("#{index_name}_for", new_field_value)
192
+ new_index_set = scope_instance.send("#{index_name}_for", new_field_value)
192
193
  new_index_set.add(identifier)
193
194
  end
194
195
  end