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
@@ -49,11 +49,11 @@ module Familia
49
49
  # @param indexed_class [Class] The class being indexed (e.g., Employee)
50
50
  # @param field [Symbol] The field to index
51
51
  # @param index_name [Symbol] Name of the index
52
- # @param within [Class, Symbol, nil] Parent class for instance-scoped index
52
+ # @param within [Class, Symbol, nil] Scope class for instance-scoped index
53
53
  # @param query [Boolean] Whether to generate query methods
54
54
  def setup(indexed_class:, field:, index_name:, within:, query:)
55
55
  # Normalize parameters and determine scope type
56
- target_class, scope_type = if within
56
+ scope_class, scope_type = if within
57
57
  k = Familia.resolve_class(within)
58
58
  [k, :instance]
59
59
  else
@@ -63,7 +63,8 @@ module Familia
63
63
  # Store metadata for this indexing relationship
64
64
  indexed_class.indexing_relationships << IndexingRelationship.new(
65
65
  field: field,
66
- target_class: target_class,
66
+ scope_class: scope_class,
67
+ within: within,
67
68
  index_name: index_name,
68
69
  query: query,
69
70
  cardinality: :unique,
@@ -73,10 +74,10 @@ module Familia
73
74
  case scope_type
74
75
  when :instance
75
76
  # Instance-scoped index (within: Company)
76
- if query && target_class.is_a?(Class)
77
- generate_query_methods_destination(indexed_class, field, target_class, index_name)
77
+ if query && scope_class.is_a?(Class)
78
+ generate_query_methods_destination(indexed_class, field, scope_class, index_name)
78
79
  end
79
- generate_mutation_methods_self(indexed_class, field, target_class, index_name)
80
+ generate_mutation_methods_self(indexed_class, field, scope_class, index_name)
80
81
  when :class
81
82
  # Class-level index (no within:)
82
83
  indexed_class.send(:ensure_index_field, indexed_class, index_name, :class_hashkey)
@@ -85,7 +86,8 @@ module Familia
85
86
  end
86
87
  end
87
88
 
88
- # Generates query methods ON THE PARENT CLASS (Company when within: Company):
89
+ # Generates query methods ON THE SCOPE CLASS (Company when within: Company)
90
+ #
89
91
  # - company.find_by_badge_number(badge) - find by field value
90
92
  # - company.find_all_by_badge_number([badges]) - batch lookup
91
93
  # - company.badge_index - DataType accessor
@@ -93,17 +95,17 @@ module Familia
93
95
  #
94
96
  # @param indexed_class [Class] The class being indexed (e.g., Employee)
95
97
  # @param field [Symbol] The field to index (e.g., :badge_number)
96
- # @param target_class [Class] The parent class (e.g., Company)
98
+ # @param scope_class [Class] The scope class providing uniqueness context (e.g., Company)
97
99
  # @param index_name [Symbol] Name of the index (e.g., :badge_index)
98
- def generate_query_methods_destination(indexed_class, field, target_class, index_name)
99
- # Resolve target class using Familia pattern
100
- actual_target_class = Familia.resolve_class(target_class)
100
+ def generate_query_methods_destination(indexed_class, field, scope_class, index_name)
101
+ # Resolve scope class using Familia pattern
102
+ actual_scope_class = Familia.resolve_class(scope_class)
101
103
 
102
104
  # Ensure the index field is declared (creates accessor that returns DataType)
103
- actual_target_class.send(:ensure_index_field, actual_target_class, index_name, :hashkey)
105
+ actual_scope_class.send(:ensure_index_field, actual_scope_class, index_name, :hashkey)
104
106
 
105
107
  # Generate instance query method (e.g., company.find_by_badge_number)
106
- actual_target_class.class_eval do
108
+ actual_scope_class.class_eval do
107
109
  define_method(:"find_by_#{field}") do |provided_value|
108
110
  # Use declared field accessor instead of manual instantiation
109
111
  index_hash = send(index_name)
@@ -121,7 +123,10 @@ module Familia
121
123
 
122
124
  # Generate bulk query method (e.g., company.find_all_by_badge_number)
123
125
  define_method(:"find_all_by_#{field}") do |provided_ids|
124
- provided_ids = Array(provided_ids)
126
+ # Convert to array and filter nil inputs before querying Redis.
127
+ # This prevents wasteful lookups for empty string keys (nil.to_s → "").
128
+ # Output may contain fewer elements than input (standard ORM behavior).
129
+ provided_ids = Array(provided_ids).compact
125
130
  return [] if provided_ids.empty?
126
131
 
127
132
  # Use declared field accessor instead of manual instantiation
@@ -129,7 +134,8 @@ module Familia
129
134
 
130
135
  # Get all identifiers from the hash
131
136
  record_ids = index_hash.values_at(*provided_ids.map(&:to_s))
132
- # Filter out nil values and instantiate objects
137
+
138
+ # Filter out nil values (non-existent records) and instantiate objects
133
139
  record_ids.compact.map { |record_id|
134
140
  indexed_class.find_by_identifier(record_id)
135
141
  }
@@ -153,63 +159,102 @@ module Familia
153
159
  end
154
160
  end
155
161
 
156
- # Generates mutation methods ON THE INDEXED CLASS (Employee):
157
- # Instance methods for parent-scoped unique index operations:
158
- # - employee.add_to_company_badge_index(company)
162
+ # Generates mutation methods ON THE INDEXED CLASS (Employee)
163
+ #
164
+ # Instance methods for scope-scoped unique index operations:
165
+ # - employee.add_to_company_badge_index(company) - automatically validates uniqueness
159
166
  # - employee.remove_from_company_badge_index(company)
160
167
  # - employee.update_in_company_badge_index(company, old_badge)
168
+ # - employee.guard_unique_company_badge_index!(company) - manual validation
161
169
  #
162
170
  # @param indexed_class [Class] The class being indexed (e.g., Employee)
163
171
  # @param field [Symbol] The field to index (e.g., :badge_number)
164
- # @param target_class [Class] The parent class (e.g., Company)
172
+ # @param scope_class [Class] The scope class providing uniqueness context (e.g., Company)
165
173
  # @param index_name [Symbol] Name of the index (e.g., :badge_index)
166
- def generate_mutation_methods_self(indexed_class, field, target_class, index_name)
167
- target_class_config = target_class.config_name
174
+ def generate_mutation_methods_self(indexed_class, field, scope_class, index_name)
175
+ scope_class_config = scope_class.config_name
168
176
  indexed_class.class_eval do
169
- method_name = :"add_to_#{target_class_config}_#{index_name}"
177
+ method_name = :"add_to_#{scope_class_config}_#{index_name}"
170
178
  Familia.ld("[UniqueIndexGenerators] #{name} method #{method_name}")
171
179
 
172
- define_method(method_name) do |target_instance|
173
- return unless target_instance
180
+ define_method(method_name) do |scope_instance|
181
+ return unless scope_instance
174
182
 
175
183
  field_value = send(field)
176
184
  return unless field_value
177
185
 
178
- # Use declared field accessor on target instance
179
- index_hash = target_instance.send(index_name)
186
+ # Automatically validate uniqueness before adding to index.
187
+ # Skip validation inside transactions since guard methods require read
188
+ # operations not available in MULTI/EXEC blocks.
189
+ unless Fiber[:familia_transaction]
190
+ guard_method = :"guard_unique_#{scope_class_config}_#{index_name}!"
191
+ send(guard_method, scope_instance) if respond_to?(guard_method)
192
+ end
193
+
194
+ # Use declared field accessor on scope instance
195
+ index_hash = scope_instance.send(index_name)
180
196
 
181
- # Use HashKey DataType method
197
+ # Set the value (guard already validated uniqueness)
182
198
  index_hash[field_value.to_s] = identifier
183
199
  end
184
200
 
185
- method_name = :"remove_from_#{target_class_config}_#{index_name}"
201
+ # Add a guard method to enforce unique constraint on this instance-scoped index
202
+ #
203
+ # @param scope_instance [Object] The scope instance providing uniqueness context (e.g., a Company)
204
+ # @raise [Familia::RecordExistsError] if a record with the same field value
205
+ # exists in the scope's index. Values are compared as strings.
206
+ # @return [void]
207
+ #
208
+ # @example
209
+ # employee.guard_unique_company_badge_index!(company)
210
+ #
211
+ method_name = :"guard_unique_#{scope_class_config}_#{index_name}!"
186
212
  Familia.ld("[UniqueIndexGenerators] #{name} method #{method_name}")
187
213
 
188
- define_method(method_name) do |target_instance|
189
- return unless target_instance
214
+ define_method(method_name) do |scope_instance|
215
+ return unless scope_instance
190
216
 
191
217
  field_value = send(field)
192
218
  return unless field_value
193
219
 
194
- # Use declared field accessor on target instance
195
- index_hash = target_instance.send(index_name)
220
+ # Use declared field accessor on scope instance
221
+ index_hash = scope_instance.send(index_name)
222
+ existing_id = index_hash.get(field_value.to_s)
223
+
224
+ if existing_id && existing_id != identifier
225
+ raise Familia::RecordExistsError,
226
+ "#{self.class} exists in #{scope_instance.class} with #{field}=#{field_value}"
227
+ end
228
+ end
229
+
230
+ method_name = :"remove_from_#{scope_class_config}_#{index_name}"
231
+ Familia.ld("[UniqueIndexGenerators] #{name} method #{method_name}")
232
+
233
+ define_method(method_name) do |scope_instance|
234
+ return unless scope_instance
235
+
236
+ field_value = send(field)
237
+ return unless field_value
238
+
239
+ # Use declared field accessor on scope instance
240
+ index_hash = scope_instance.send(index_name)
196
241
 
197
242
  # Remove using HashKey DataType method
198
243
  index_hash.remove(field_value.to_s)
199
244
  end
200
245
 
201
- method_name = :"update_in_#{target_class_config}_#{index_name}"
246
+ method_name = :"update_in_#{scope_class_config}_#{index_name}"
202
247
  Familia.ld("[UniqueIndexGenerators] #{name} method #{method_name}")
203
248
 
204
- define_method(method_name) do |target_instance, old_field_value = nil|
205
- return unless target_instance
249
+ define_method(method_name) do |scope_instance, old_field_value = nil|
250
+ return unless scope_instance
206
251
 
207
252
  new_field_value = send(field)
208
253
 
209
254
  # Use Familia's transaction method for atomicity with DataType abstraction
210
- target_instance.transaction do |_tx|
211
- # Use declared field accessor on target instance
212
- index_hash = target_instance.send(index_name)
255
+ scope_instance.transaction do |_tx|
256
+ # Use declared field accessor on scope instance
257
+ index_hash = scope_instance.send(index_name)
213
258
 
214
259
  # Remove old value if provided
215
260
  index_hash.remove(old_field_value.to_s) if old_field_value
@@ -228,10 +273,12 @@ module Familia
228
273
  # - Employee.email_index
229
274
  # - Employee.rebuild_email_index
230
275
  def generate_query_methods_class(field, index_name, indexed_class)
276
+ # Generate class-level single record method
231
277
  indexed_class.define_singleton_method(:"find_by_#{field}") do |provided_id|
232
- index_hash = send(index_name) # Access the class-level hashkey DataType
278
+ index_hash = send(index_name) # access the class-level hashkey DataType
233
279
 
234
- # Get the identifier from the hash using .get method.
280
+ # Get the identifier from the db hashkey using .get method.
281
+ #
235
282
  # We use .get instead of [] because it's part of the standard interface
236
283
  # common across all DataType classes (List, UnsortedSet, SortedSet, HashKey).
237
284
  # While unique indexes always use HashKey, using .get maintains consistency
@@ -245,12 +292,18 @@ module Familia
245
292
 
246
293
  # Generate class-level bulk query method
247
294
  indexed_class.define_singleton_method(:"find_all_by_#{field}") do |provided_ids|
248
- provided_ids = Array(provided_ids)
295
+ # Convert to array and filter nil inputs before querying Redis.
296
+ # This prevents wasteful lookups for empty string keys (nil.to_s → "").
297
+ # Output may contain fewer elements than input (standard ORM behavior).
298
+ provided_ids = Array(provided_ids).compact
249
299
  return [] if provided_ids.empty?
250
300
 
251
- index_hash = send(index_name) # Access the class-level hashkey DataType
301
+ index_hash = send(index_name) # access the class-level hashkey DataType
302
+
303
+ # Get multiple identifiers from the db hashkey using .values_at
252
304
  record_ids = index_hash.values_at(*provided_ids.map(&:to_s))
253
- # Filter out nil values and instantiate objects
305
+
306
+ # Filter out nil values (non-existent records) and instantiate objects
254
307
  record_ids.compact.map { |record_id|
255
308
  indexed_class.find_by_identifier(record_id)
256
309
  }
@@ -285,9 +338,28 @@ module Familia
285
338
 
286
339
  return unless field_value
287
340
 
341
+ # Just set the value - uniqueness should be validated before save
288
342
  index_hash[field_value.to_s] = identifier
289
343
  end
290
344
 
345
+ # Add a guard method to enforce unique constraint on this specific index
346
+ #
347
+ # @raise [Familia::RecordExistsError] if a record with the same
348
+ # field value exists. Values are compared as strings.
349
+ #
350
+ # @return [void]
351
+ define_method(:"guard_unique_#{index_name}!") do
352
+ field_value = send(field)
353
+ return unless field_value
354
+
355
+ index_hash = self.class.send(index_name)
356
+ existing_id = index_hash.get(field_value.to_s)
357
+
358
+ if existing_id && existing_id != identifier
359
+ raise Familia::RecordExistsError, "#{self.class} exists #{field}=#{field_value}"
360
+ end
361
+ end
362
+
291
363
  define_method(:"remove_from_class_#{index_name}") do
292
364
  index_hash = self.class.send(index_name) # Access the class-level hashkey DataType
293
365
  field_value = send(field)
@@ -50,7 +50,7 @@ module Familia
50
50
  # Terminology:
51
51
  # - unique_index: 1:1 field-to-object mapping (HashKey)
52
52
  # - multi_index: 1:many field-to-objects mapping (UnsortedSet, no scores)
53
- # - within: parent class for instance-scoped indexes
53
+ # - within: scope class providing uniqueness boundary for instance-scoped indexes
54
54
  # - query: whether to generate find_by_* methods (default: true)
55
55
  #
56
56
  # Key Patterns:
@@ -89,7 +89,7 @@ module Familia
89
89
  #
90
90
  # @param field [Symbol] The field to index on
91
91
  # @param index_name [Symbol] Name of the index
92
- # @param within [Class, Symbol] The parent class that owns the index
92
+ # @param within [Class, Symbol] The scope class providing uniqueness context
93
93
  # @param query [Boolean] Whether to generate query methods
94
94
  #
95
95
  # @example Instance-scoped multi-value indexing
@@ -109,7 +109,7 @@ module Familia
109
109
  #
110
110
  # @param field [Symbol] The field to index on
111
111
  # @param index_name [Symbol] Name of the index hash
112
- # @param within [Class, Symbol] Optional parent class for instance-scoped unique index
112
+ # @param within [Class, Symbol] Optional scope class for instance-scoped unique index
113
113
  # @param query [Boolean] Whether to generate query methods
114
114
  #
115
115
  # @example Class-level unique index
@@ -136,70 +136,68 @@ module Familia
136
136
 
137
137
  # Ensure proper DataType field is declared for index
138
138
  # Similar to ensure_collection_field in participation system
139
- def ensure_index_field(target_class, index_name, field_type)
140
- return if target_class.method_defined?(index_name) || target_class.respond_to?(index_name)
139
+ def ensure_index_field(scope_class, index_name, field_type)
140
+ return if scope_class.method_defined?(index_name) || scope_class.respond_to?(index_name)
141
141
 
142
- target_class.send(field_type, index_name)
142
+ scope_class.send(field_type, index_name)
143
143
  end
144
144
  end
145
145
 
146
146
  # Instance methods for indexed objects
147
147
  module ModelInstanceMethods
148
- # Update all indexes for a given parent context
149
- # For class-level indexes (class_indexed_by), parent_context should be nil
150
- # For relationship indexes (indexed_by), parent_context should be the parent instance
151
- def update_all_indexes(old_values = {}, parent_context = nil)
148
+ # Update all indexes for a given scope context
149
+ # For class-level indexes (unique_index without within:), scope_context should be nil
150
+ # For instance-scoped indexes (with within:), scope_context should be the scope instance
151
+ def update_all_indexes(old_values = {}, scope_context = nil)
152
152
  return unless self.class.respond_to?(:indexing_relationships)
153
153
 
154
154
  self.class.indexing_relationships.each do |config|
155
155
  field = config.field
156
156
  index_name = config.index_name
157
- target_class = config.target_class
158
157
  old_field_value = old_values[field]
159
158
 
160
159
  # Determine which update method to call
161
- if target_class == self.class
160
+ if config.within.nil?
162
161
  # Class-level index (unique_index without within:)
163
162
  send("update_in_class_#{index_name}", old_field_value)
164
163
  else
165
- # Relationship index (unique_index or multi_index with within:) - requires parent context
166
- next unless parent_context
164
+ # Instance-scoped index (unique_index or multi_index with within:) - requires scope context
165
+ next unless scope_context
167
166
 
168
167
  # Use config_name for method naming
169
- target_class_config = Familia.resolve_class(config.target_class).config_name
170
- send("update_in_#{target_class_config}_#{index_name}", parent_context, old_field_value)
168
+ scope_class_config = Familia.resolve_class(config.scope_class).config_name
169
+ send("update_in_#{scope_class_config}_#{index_name}", scope_context, old_field_value)
171
170
  end
172
171
  end
173
172
  end
174
173
 
175
- # Remove from all indexes for a given parent context
176
- # For class-level indexes (class_indexed_by), parent_context should be nil
177
- # For relationship indexes (indexed_by), parent_context should be the parent instance
178
- def remove_from_all_indexes(parent_context = nil)
174
+ # Remove from all indexes for a given scope context
175
+ # For class-level indexes (unique_index without within:), scope_context should be nil
176
+ # For instance-scoped indexes (with within:), scope_context should be the scope instance
177
+ def remove_from_all_indexes(scope_context = nil)
179
178
  return unless self.class.respond_to?(:indexing_relationships)
180
179
 
181
180
  self.class.indexing_relationships.each do |config|
182
181
  index_name = config.index_name
183
- target_class = config.target_class
184
182
 
185
183
  # Determine which remove method to call
186
- if target_class == self.class
184
+ if config.within.nil?
187
185
  # Class-level index (unique_index without within:)
188
186
  send("remove_from_class_#{index_name}")
189
187
  else
190
- # Relationship index (unique_index or multi_index with within:) - requires parent context
191
- next unless parent_context
188
+ # Instance-scoped index (unique_index or multi_index with within:) - requires scope context
189
+ next unless scope_context
192
190
 
193
191
  # Use config_name for method naming
194
- target_class_config = Familia.resolve_class(config.target_class).config_name
195
- send("remove_from_#{target_class_config}_#{index_name}", parent_context)
192
+ scope_class_config = Familia.resolve_class(config.scope_class).config_name
193
+ send("remove_from_#{scope_class_config}_#{index_name}", scope_context)
196
194
  end
197
195
  end
198
196
  end
199
197
 
200
198
  # Get all indexes this object appears in
201
- # Note: For target-scoped indexes, this only shows class-level indexes
202
- # since target-scoped indexes require a specific target instance
199
+ # Note: For instance-scoped indexes, this only shows class-level indexes
200
+ # since instance-scoped indexes require a specific scope instance
203
201
  #
204
202
  # @return [Array<Hash>] Array of index information
205
203
  def current_indexings
@@ -210,19 +208,18 @@ module Familia
210
208
  self.class.indexing_relationships.each do |config|
211
209
  field = config.field
212
210
  index_name = config.index_name
213
- target_class = config.target_class
214
211
  cardinality = config.cardinality
215
212
  field_value = send(field)
216
213
 
217
214
  next unless field_value
218
215
 
219
- if target_class == self.class
216
+ if config.within.nil?
220
217
  # Class-level index (unique_index without within:) - check hash key using DataType
221
218
  index_hash = self.class.send(index_name)
222
219
  next unless index_hash.key?(field_value.to_s)
223
220
 
224
221
  memberships << {
225
- target_class: 'class',
222
+ scope_class: 'class',
226
223
  index_name: index_name,
227
224
  field: field,
228
225
  field_value: field_value,
@@ -231,17 +228,17 @@ module Familia
231
228
  type: 'unique_index',
232
229
  }
233
230
  else
234
- # Instance-scoped index (unique_index or multi_index with within:) - cannot check without target instance
235
- # This would require scanning all possible target instances
231
+ # Instance-scoped index (unique_index or multi_index with within:) - cannot check without scope instance
232
+ # This would require scanning all possible scope instances
236
233
  memberships << {
237
- target_class: config.target_class_config_name,
234
+ scope_class: config.scope_class_config_name,
238
235
  index_name: index_name,
239
236
  field: field,
240
237
  field_value: field_value,
241
- index_key: 'target_dependent',
238
+ index_key: 'scope_dependent',
242
239
  cardinality: cardinality,
243
240
  type: cardinality == :unique ? 'unique_index' : 'multi_index',
244
- note: 'Requires target instance for verification',
241
+ note: 'Requires scope instance for verification',
245
242
  }
246
243
  end
247
244
  end
@@ -249,9 +246,9 @@ module Familia
249
246
  memberships
250
247
  end
251
248
 
252
- # Check if this object is indexed in a specific target
249
+ # Check if this object is indexed in a specific scope
253
250
  # For class-level indexes, checks the hash key
254
- # For target-scoped indexes, returns false (requires target instance)
251
+ # For instance-scoped indexes, returns false (requires scope instance)
255
252
  def indexed_in?(index_name)
256
253
  return false unless self.class.respond_to?(:indexing_relationships)
257
254
 
@@ -262,14 +259,12 @@ module Familia
262
259
  field_value = send(field)
263
260
  return false unless field_value
264
261
 
265
- target_class = config.target_class
266
-
267
- if target_class == self.class
262
+ if config.within.nil?
268
263
  # Class-level index (class_indexed_by) - check hash key using DataType
269
264
  index_hash = self.class.send(index_name)
270
265
  index_hash.key?(field_value.to_s)
271
266
  else
272
- # Target-scoped index (indexed_by) - cannot verify without target instance
267
+ # Instance-scoped index (with within:) - cannot verify without scope instance
273
268
  false
274
269
  end
275
270
  end
@@ -14,20 +14,30 @@ module Familia
14
14
  # Similar to ParticipationRelationship but for attribute-based lookups
15
15
  # rather than collection membership.
16
16
  #
17
+ # Terminology:
18
+ # - `scope_class`: The class that provides the uniqueness boundary for
19
+ # instance-scoped indexes. For example, in `unique_index :badge_number,
20
+ # :badge_index, within: Company`, the Company is the scope class.
21
+ # - `within`: Preserves the original DSL parameter to explicitly distinguish
22
+ # class-level indexes (within: nil) from instance-scoped indexes (within:
23
+ # SomeClass). This avoids brittle class comparisons and prevents issues
24
+ # with inheritance scenarios.
25
+ #
17
26
  IndexingRelationship = Data.define(
18
27
  :field, # Symbol - field being indexed (e.g., :email, :department)
19
28
  :index_name, # Symbol - name of the index (e.g., :email_index, :dept_index)
20
- :target_class, # Class/Symbol - parent class for instance-scoped indexes (within:)
29
+ :scope_class, # Class/Symbol - scope class for instance-scoped indexes (within:)
30
+ :within, # Class/Symbol/nil - within: parameter (nil for class-level, Class for instance-scoped)
21
31
  :cardinality, # Symbol - :unique (1:1) or :multi (1:many)
22
32
  :query # Boolean - whether to generate query methods
23
33
  ) do
24
34
  #
25
- # Get the normalized config name for the target class
35
+ # Get the normalized config name for the scope class
26
36
  #
27
37
  # @return [String] The config name (e.g., "user", "company", "test_company")
28
38
  #
29
- def target_class_config_name
30
- target_class.config_name
39
+ def scope_class_config_name
40
+ scope_class.config_name
31
41
  end
32
42
  end
33
43
  end
@@ -143,7 +143,8 @@ module Familia
143
143
  val = args.first
144
144
 
145
145
  # If no value provided, return current stored value
146
- return hget(field_name) if val.nil?
146
+ # Handle Redis::Future objects during transactions
147
+ return hget(field_name) if val.nil? || val.is_a?(Redis::Future)
147
148
 
148
149
  begin
149
150
  # Trace the operation if debugging is enabled
@@ -6,40 +6,16 @@ module Familia
6
6
  # Provides connection handling, transactions, and URI normalization for both
7
7
  # class-level operations (e.g., Customer.dbclient) and instance-level operations
8
8
  # (e.g., customer.dbclient)
9
+ #
10
+ # Includes shared connection behavior from Familia::Connection::Behavior, providing:
11
+ # - URI normalization (normalize_uri)
12
+ # - Connection creation (create_dbclient)
13
+ # - Transaction method signatures
14
+ # - Pipeline method signatures
9
15
  module Connection
10
- attr_reader :uri
11
-
12
- # Normalizes various URI formats to a consistent URI object
13
- # Considers the class/instance logical_database when uri is nil or Integer
14
- def normalize_uri(uri)
15
- case uri
16
- when Integer
17
- new_uri = Familia.uri.dup
18
- new_uri.db = uri
19
- new_uri
20
- when ->(obj) { obj.is_a?(String) || obj.instance_of?(::String) }
21
- URI.parse(uri)
22
- when URI
23
- uri
24
- when nil
25
- # Use logical_database if available, otherwise fall back to Familia.uri
26
- if respond_to?(:logical_database) && logical_database
27
- new_uri = Familia.uri.dup
28
- new_uri.db = logical_database
29
- new_uri
30
- else
31
- Familia.uri
32
- end
33
- else
34
- raise ArgumentError, "Invalid URI type: #{uri.class.name}"
35
- end
36
- end
16
+ include Familia::Connection::Behavior
37
17
 
38
- # Creates a new Database connection instance using the class/instance configuration
39
- def create_dbclient(uri = nil)
40
- parsed_uri = normalize_uri(uri)
41
- Familia.create_dbclient(parsed_uri)
42
- end
18
+ attr_reader :uri
43
19
 
44
20
  # Returns the Database connection for the class using Chain of Responsibility pattern.
45
21
  #
@@ -277,9 +253,9 @@ module Familia
277
253
  @provider_connection_handler ||= Familia::Connection::ProviderConnectionHandler.new
278
254
 
279
255
  # Determine the appropriate class context
280
- # When called from instance: self is instance, self.class is the model class
281
- # When called from class: self is the model class
282
- klass = self.is_a?(Class) ? self : self.class
256
+ # When called from instance: self is instance, use the model class connection
257
+ # When called from class: we'll use our own connection
258
+ klass = is_a?(Class) ? self : self.class
283
259
 
284
260
  # Always check class first for @dbclient since instance-level connections were removed
285
261
  @cached_connection_handler ||= Familia::Connection::CachedConnectionHandler.new(klass)