familia 2.0.0.pre21 → 2.0.0.pre23

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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/claude-code-review.yml +8 -5
  3. data/.talismanrc +5 -1
  4. data/CHANGELOG.rst +76 -0
  5. data/Gemfile.lock +8 -8
  6. data/docs/1106-participates_in-bidirectional-solution.md +201 -58
  7. data/examples/through_relationships.rb +275 -0
  8. data/lib/familia/connection/operation_core.rb +1 -2
  9. data/lib/familia/connection/pipelined_core.rb +1 -3
  10. data/lib/familia/connection/transaction_core.rb +1 -2
  11. data/lib/familia/data_type/serialization.rb +76 -51
  12. data/lib/familia/data_type/types/sorted_set.rb +5 -10
  13. data/lib/familia/data_type/types/stringkey.rb +22 -0
  14. data/lib/familia/features/external_identifier.rb +29 -0
  15. data/lib/familia/features/object_identifier.rb +47 -0
  16. data/lib/familia/features/relationships/README.md +1 -1
  17. data/lib/familia/features/relationships/indexing/rebuild_strategies.rb +15 -15
  18. data/lib/familia/features/relationships/indexing/unique_index_generators.rb +8 -0
  19. data/lib/familia/features/relationships/participation/participant_methods.rb +59 -10
  20. data/lib/familia/features/relationships/participation/target_methods.rb +51 -7
  21. data/lib/familia/features/relationships/participation/through_model_operations.rb +150 -0
  22. data/lib/familia/features/relationships/participation.rb +39 -15
  23. data/lib/familia/features/relationships/participation_relationship.rb +19 -1
  24. data/lib/familia/features/relationships.rb +1 -1
  25. data/lib/familia/horreum/database_commands.rb +6 -1
  26. data/lib/familia/horreum/management.rb +141 -10
  27. data/lib/familia/horreum/persistence.rb +3 -0
  28. data/lib/familia/identifier_extractor.rb +1 -1
  29. data/lib/familia/version.rb +1 -1
  30. data/lib/multi_result.rb +59 -31
  31. data/pr_agent.toml +6 -1
  32. data/try/features/count_any_edge_cases_try.rb +486 -0
  33. data/try/features/count_any_methods_try.rb +197 -0
  34. data/try/features/external_identifier/external_identifier_try.rb +134 -0
  35. data/try/features/object_identifier/object_identifier_try.rb +138 -0
  36. data/try/features/relationships/indexing_rebuild_try.rb +6 -0
  37. data/try/features/relationships/participation_commands_verification_spec.rb +1 -1
  38. data/try/features/relationships/participation_commands_verification_try.rb +1 -1
  39. data/try/features/relationships/participation_method_prefix_try.rb +133 -0
  40. data/try/features/relationships/participation_reverse_index_try.rb +1 -1
  41. data/try/features/relationships/{participation_bidirectional_try.rb → participation_reverse_methods_try.rb} +6 -6
  42. data/try/features/relationships/participation_through_try.rb +173 -0
  43. data/try/integration/data_types/datatype_pipelines_try.rb +5 -3
  44. data/try/integration/data_types/datatype_transactions_try.rb +13 -7
  45. data/try/integration/models/customer_try.rb +3 -3
  46. data/try/unit/data_types/boolean_try.rb +35 -22
  47. data/try/unit/data_types/hash_try.rb +2 -2
  48. data/try/unit/data_types/serialization_try.rb +386 -0
  49. data/try/unit/horreum/destroy_related_fields_cleanup_try.rb +2 -1
  50. metadata +9 -8
  51. data/changelog.d/20251105_flexible_external_identifier_format.rst +0 -66
  52. data/changelog.d/20251107_112554_delano_179_participation_asymmetry.rst +0 -44
  53. data/changelog.d/20251107_213121_delano_fix_thread_safety_races_011CUumCP492Twxm4NLt2FvL.rst +0 -20
  54. data/changelog.d/20251107_fix_participates_in_symbol_resolution.rst +0 -91
  55. data/changelog.d/20251107_optimized_redis_exists_checks.rst +0 -94
  56. data/changelog.d/20251108_frozen_string_literal_pragma.rst +0 -44
@@ -76,7 +76,7 @@ module Familia
76
76
  # batch_size: 100
77
77
  # ) { |p| puts "#{p[:completed]}/#{p[:total]} (#{p[:rate]}/s)" }
78
78
  #
79
- def rebuild_via_instances(indexed_class, field, add_method, batch_size: 100, &progress)
79
+ def rebuild_via_instances(indexed_class, field, add_method, index_hashkey, batch_size: 100, &progress)
80
80
  unless indexed_class.respond_to?(:instances)
81
81
  raise ArgumentError, "#{indexed_class.name} does not have an instances collection"
82
82
  end
@@ -104,8 +104,8 @@ module Familia
104
104
  processed = 0
105
105
  indexed_count = 0
106
106
 
107
- # Process in batches - use membersraw to get raw identifiers without deserialization
108
- instances.membersraw.each_slice(batch_size) do |identifiers|
107
+ # Process in batches - use members to get deserialized identifiers
108
+ instances.members.each_slice(batch_size) do |identifiers|
109
109
  # Bulk load objects, filtering out nils (deleted/missing objects)
110
110
  objects = indexed_class.load_multi(identifiers).compact
111
111
 
@@ -117,8 +117,8 @@ module Familia
117
117
  # Skip nil/empty field values gracefully
118
118
  next unless value && !value.to_s.strip.empty?
119
119
 
120
- # For class-level indexes, use HSET directly into temp key
121
- tx.hset(temp_key, value.to_s, obj.identifier.to_s)
120
+ # For class-level indexes, use HSET with serialized value for consistency
121
+ tx.hset(temp_key, value.to_s, index_hashkey.serialize_value(obj))
122
122
  batch_indexed += 1
123
123
  end
124
124
  end
@@ -177,7 +177,7 @@ module Familia
177
177
  # batch_size: 100
178
178
  # )
179
179
  #
180
- def rebuild_via_participation(scope_instance, indexed_class, field, add_method, collection, cardinality, batch_size: 100, &progress)
180
+ def rebuild_via_participation(scope_instance, indexed_class, field, add_method, collection, cardinality, index_hashkey, batch_size: 100, &progress)
181
181
  total = collection.size
182
182
  start_time = Familia.now
183
183
 
@@ -222,8 +222,8 @@ module Familia
222
222
  processed = 0
223
223
  indexed_count = 0
224
224
 
225
- # Process in batches - use membersraw to get raw identifiers
226
- collection.membersraw.each_slice(batch_size) do |identifiers|
225
+ # Process in batches - use members to get deserialized identifiers
226
+ collection.members.each_slice(batch_size) do |identifiers|
227
227
  objects = indexed_class.load_multi(identifiers).compact
228
228
 
229
229
  # Transaction per batch
@@ -233,9 +233,9 @@ module Familia
233
233
  value = obj.send(field)
234
234
  next unless value && !value.to_s.strip.empty?
235
235
 
236
- # For unique index: HSET temp_key field_value identifier
236
+ # For unique index: HSET temp_key field_value serialized_identifier
237
237
  # For multi-index: SADD temp_key:field_value identifier
238
- tx.hset(temp_key, value.to_s, obj.identifier.to_s)
238
+ tx.hset(temp_key, value.to_s, index_hashkey.serialize_value(obj))
239
239
  batch_indexed += 1
240
240
  end
241
241
  end
@@ -295,7 +295,7 @@ module Familia
295
295
  # batch_size: 100
296
296
  # )
297
297
  #
298
- def rebuild_via_scan(indexed_class, field, add_method, scope_instance: nil, batch_size: 100, &progress)
298
+ def rebuild_via_scan(indexed_class, field, add_method, index_hashkey, scope_instance: nil, batch_size: 100, &progress)
299
299
  start_time = Familia.now
300
300
 
301
301
  # Build key pattern for SCAN
@@ -342,7 +342,7 @@ module Familia
342
342
 
343
343
  # Process in batches
344
344
  if batch.size >= batch_size
345
- batch_indexed = RebuildStrategies.process_scan_batch(batch, indexed_class, field, temp_key, scope_instance)
345
+ batch_indexed = RebuildStrategies.process_scan_batch(batch, indexed_class, field, temp_key, index_hashkey, scope_instance)
346
346
  processed += batch.size
347
347
  indexed_count += batch_indexed
348
348
 
@@ -362,7 +362,7 @@ module Familia
362
362
 
363
363
  # Process remaining batch
364
364
  unless batch.empty?
365
- batch_indexed = RebuildStrategies.process_scan_batch(batch, indexed_class, field, temp_key, scope_instance)
365
+ batch_indexed = RebuildStrategies.process_scan_batch(batch, indexed_class, field, temp_key, index_hashkey, scope_instance)
366
366
  processed += batch.size
367
367
  indexed_count += batch_indexed
368
368
  end
@@ -385,7 +385,7 @@ module Familia
385
385
  # @param scope_instance [Object, nil] Optional scope instance. If provided, only objects belonging to this scope will be indexed.
386
386
  # @return [Integer] Number of objects indexed in this batch
387
387
  #
388
- def process_scan_batch(keys, indexed_class, field, temp_key, scope_instance)
388
+ def process_scan_batch(keys, indexed_class, field, temp_key, index_hashkey, scope_instance)
389
389
  # Load objects by keys
390
390
  objects = indexed_class.load_multi_by_keys(keys).compact
391
391
 
@@ -411,7 +411,7 @@ module Familia
411
411
  value = obj.send(field)
412
412
  next unless value && !value.to_s.strip.empty?
413
413
 
414
- tx.hset(temp_key, value.to_s, obj.identifier.to_s)
414
+ tx.hset(temp_key, value.to_s, index_hashkey.serialize_value(obj))
415
415
  batch_indexed += 1
416
416
  end
417
417
  end
@@ -191,6 +191,7 @@ module Familia
191
191
  index_config = indexed_class.indexing_relationships.find { |rel| rel.index_name == index_name }
192
192
 
193
193
  # Strategy 2: Use participation-based rebuild
194
+ index_hashkey = send(index_name) # Get the index HashKey for serialization
194
195
  Familia::Features::Relationships::Indexing::RebuildStrategies.rebuild_via_participation(
195
196
  self, # scope_instance (e.g., company)
196
197
  indexed_class, # e.g., Employee
@@ -198,15 +199,18 @@ module Familia
198
199
  :"add_to_#{scope_class_config}_#{index_name}", # e.g., :add_to_company_badge_index
199
200
  collection,
200
201
  index_config.cardinality, # :unique or :multi
202
+ index_hashkey, # Pass index for serialization
201
203
  batch_size: batch_size,
202
204
  &progress_block
203
205
  )
204
206
  else
205
207
  # Strategy 3: Fall back to SCAN with filtering
208
+ index_hashkey = send(index_name) # Get the index HashKey for serialization
206
209
  Familia::Features::Relationships::Indexing::RebuildStrategies.rebuild_via_scan(
207
210
  indexed_class,
208
211
  field,
209
212
  :"add_to_#{scope_class_config}_#{index_name}",
213
+ index_hashkey, # Pass index for serialization
210
214
  scope_instance: self,
211
215
  batch_size: batch_size,
212
216
  &progress_block
@@ -373,19 +377,23 @@ module Familia
373
377
  indexed_class.define_singleton_method(:"rebuild_#{index_name}") do |batch_size: 100, &progress_block|
374
378
  if respond_to?(:instances)
375
379
  # Strategy 1: Use instances collection (fastest)
380
+ index_hashkey = send(index_name) # Get the index HashKey for serialization
376
381
  Familia::Features::Relationships::Indexing::RebuildStrategies.rebuild_via_instances(
377
382
  self, # indexed_class (e.g., User)
378
383
  field, # e.g., :email
379
384
  :"add_to_class_#{index_name}", # e.g., :add_to_class_email_lookup
385
+ index_hashkey, # Pass index for serialization
380
386
  batch_size: batch_size,
381
387
  &progress_block
382
388
  )
383
389
  else
384
390
  # Strategy 3: Fall back to SCAN
391
+ index_hashkey = send(index_name) # Get the index HashKey for serialization
385
392
  Familia::Features::Relationships::Indexing::RebuildStrategies.rebuild_via_scan(
386
393
  self,
387
394
  field,
388
395
  :"add_to_class_#{index_name}",
396
+ index_hashkey, # Pass index for serialization
389
397
  batch_size: batch_size,
390
398
  &progress_block
391
399
  )
@@ -3,6 +3,7 @@
3
3
  # frozen_string_literal: true
4
4
 
5
5
  require_relative '../collection_operations'
6
+ require_relative 'through_model_operations'
6
7
 
7
8
  module Familia
8
9
  module Features
@@ -33,6 +34,9 @@ module Familia
33
34
  module Builder
34
35
  extend CollectionOperations
35
36
 
37
+ # Include ThroughModelOperations for through model lifecycle
38
+ extend Participation::ThroughModelOperations
39
+
36
40
  # Build all participant methods for a participation relationship
37
41
  #
38
42
  # @param participant_class [Class] The class receiving these methods (e.g., Domain)
@@ -40,8 +44,9 @@ module Familia
40
44
  # @param collection_name [Symbol] Name of the collection (e.g., :domains)
41
45
  # @param type [Symbol] Collection type (:sorted_set, :set, :list)
42
46
  # @param as [Symbol, nil] Optional custom name for relationship methods (e.g., :employees)
47
+ # @param through [Symbol, Class, nil] Through model class for join table pattern
43
48
  #
44
- def self.build(participant_class, target_class, collection_name, type, as)
49
+ def self.build(participant_class, target_class, collection_name, type, as, through = nil, method_prefix = nil)
45
50
  # Determine target name based on participation context:
46
51
  # - Instance-level: target_class is a Class object (e.g., Team) → use config_name ("project_team")
47
52
  # - Class-level: target_class is the string 'class' (from class_participates_in) → use as-is
@@ -55,8 +60,8 @@ module Familia
55
60
 
56
61
  # Core participant methods
57
62
  build_membership_check(participant_class, target_name, collection_name, type)
58
- build_add_to_target(participant_class, target_name, collection_name, type)
59
- build_remove_from_target(participant_class, target_name, collection_name, type)
63
+ build_add_to_target(participant_class, target_name, collection_name, type, through)
64
+ build_remove_from_target(participant_class, target_name, collection_name, type, through)
60
65
 
61
66
  # Type-specific methods
62
67
  case type
@@ -73,18 +78,23 @@ module Familia
73
78
  # - Class-level collections are accessed directly on the class (User.all_users)
74
79
  return if target_class.is_a?(String) # 'class' indicates class-level participation
75
80
 
76
- # If `as` is specified, create a custom method for just this collection
77
- # Otherwise, add to the default pluralized method that unions all collections
81
+ # Priority for method naming:
82
+ # 1. `as:` - most specific, applies to just this collection
83
+ # 2. `method_prefix:` - applies to all collections for this target class
84
+ # 3. Default - uses target_class.config_name
78
85
  if as
79
86
  # Custom method for just this specific collection
80
87
  build_reverse_collection_methods(participant_class, target_class, as, [collection_name])
88
+ elsif method_prefix
89
+ # Custom prefix for all methods related to this target class
90
+ build_reverse_collection_methods(participant_class, target_class, method_prefix, nil)
81
91
  else
82
92
  # Default pluralized method - will include ALL collections for this target
83
93
  build_reverse_collection_methods(participant_class, target_class, nil, nil)
84
94
  end
85
95
  end
86
96
 
87
- # Generate reverse collection methods on participant class for bidirectional access
97
+ # Generate reverse collection methods on participant class for symmetric access
88
98
  #
89
99
  # Creates methods like:
90
100
  # - user.team_instances (returns Array of Team instances)
@@ -186,11 +196,11 @@ module Familia
186
196
  end
187
197
 
188
198
  # Build method to add self to target's collection
189
- # Creates: domain.add_to_customer_domains(customer, score)
190
- def self.build_add_to_target(participant_class, target_name, collection_name, type)
199
+ # Creates: domain.add_to_customer_domains(customer, score, through_attrs: {})
200
+ def self.build_add_to_target(participant_class, target_name, collection_name, type, through = nil)
191
201
  method_name = "add_to_#{target_name}_#{collection_name}"
192
202
 
193
- participant_class.define_method(method_name) do |target_instance, score = nil|
203
+ participant_class.define_method(method_name) do |target_instance, score = nil, through_attrs: {}|
194
204
  return unless target_instance&.identifier
195
205
 
196
206
  # Use Horreum's DataType accessor instead of manual creation
@@ -201,6 +211,9 @@ module Familia
201
211
  score = calculate_participation_score(target_instance.class, collection_name)
202
212
  end
203
213
 
214
+ # Resolve through class if specified
215
+ through_class = through ? Familia.resolve_class(through) : nil
216
+
204
217
  # Use transaction for atomicity between collection add and reverse index tracking
205
218
  # All operations use Horreum's DataType methods (not direct Redis calls)
206
219
  target_instance.transaction do |_tx|
@@ -217,12 +230,34 @@ module Familia
217
230
  # Track participation for efficient cleanup using DataType method (SADD)
218
231
  track_participation_in(collection.dbkey) if respond_to?(:track_participation_in)
219
232
  end
233
+
234
+ # TRANSACTION BOUNDARY: Through model operations intentionally happen AFTER
235
+ # the transaction block closes. This is a deliberate design decision because:
236
+ #
237
+ # 1. ThroughModelOperations.find_or_create performs load operations that would
238
+ # return Redis::Future objects inside a transaction, breaking the flow
239
+ # 2. The core participation (collection add + tracking) is atomic within the tx
240
+ # 3. Through model creation is logically separate - if it fails, the participation
241
+ # itself succeeded and can be cleaned up or retried independently
242
+ #
243
+ # If Familia's transaction handling changes in the future, revisit this boundary.
244
+ through_model = if through_class
245
+ Participation::ThroughModelOperations.find_or_create(
246
+ through_class: through_class,
247
+ target: target_instance,
248
+ participant: self,
249
+ attrs: through_attrs
250
+ )
251
+ end
252
+
253
+ # Return through model if using :through, otherwise self for backward compat
254
+ through_model || self
220
255
  end
221
256
  end
222
257
 
223
258
  # Build method to remove self from target's collection
224
259
  # Creates: domain.remove_from_customer_domains(customer)
225
- def self.build_remove_from_target(participant_class, target_name, collection_name, type)
260
+ def self.build_remove_from_target(participant_class, target_name, collection_name, type, through = nil)
226
261
  method_name = "remove_from_#{target_name}_#{collection_name}"
227
262
 
228
263
  participant_class.define_method(method_name) do |target_instance|
@@ -231,6 +266,9 @@ module Familia
231
266
  # Use Horreum's DataType accessor instead of manual creation
232
267
  collection = target_instance.send(collection_name)
233
268
 
269
+ # Resolve through class if specified
270
+ through_class = through ? Familia.resolve_class(through) : nil
271
+
234
272
  # Use transaction for atomicity between collection remove and reverse index untracking
235
273
  # All operations use Horreum's DataType methods (not direct Redis calls)
236
274
  target_instance.transaction do |_tx|
@@ -240,6 +278,17 @@ module Familia
240
278
  # Remove from participation tracking using DataType method (SREM)
241
279
  untrack_participation_in(collection.dbkey) if respond_to?(:untrack_participation_in)
242
280
  end
281
+
282
+ # TRANSACTION BOUNDARY: Through model destruction intentionally happens AFTER
283
+ # the transaction block. See build_add_to_target for detailed rationale.
284
+ # The core removal is atomic; through model cleanup is a separate operation.
285
+ if through_class
286
+ Participation::ThroughModelOperations.find_and_destroy(
287
+ through_class: through_class,
288
+ target: target_instance,
289
+ participant: self
290
+ )
291
+ end
243
292
  end
244
293
  end
245
294
 
@@ -3,6 +3,7 @@
3
3
  # frozen_string_literal: true
4
4
 
5
5
  require_relative '../collection_operations'
6
+ require_relative 'through_model_operations'
6
7
 
7
8
  module Familia
8
9
  module Features
@@ -30,18 +31,22 @@ module Familia
30
31
  module Builder
31
32
  extend CollectionOperations
32
33
 
34
+ # Include ThroughModelOperations for through model lifecycle
35
+ extend Participation::ThroughModelOperations
36
+
33
37
  # Build all target methods for a participation relationship
34
38
  # @param target_class [Class] The class receiving these methods (e.g., Customer)
35
39
  # @param collection_name [Symbol] Name of the collection (e.g., :domains)
36
40
  # @param type [Symbol] Collection type (:sorted_set, :set, :list)
37
- def self.build(target_class, collection_name, type)
41
+ # @param through [Symbol, Class, nil] Through model class for join table pattern
42
+ def self.build(target_class, collection_name, type, through = nil)
38
43
  # FIRST: Ensure the DataType field is defined on the target class
39
44
  TargetMethods::Builder.ensure_collection_field(target_class, collection_name, type)
40
45
 
41
46
  # Core target methods
42
47
  build_collection_getter(target_class, collection_name, type)
43
- build_add_item(target_class, collection_name, type)
44
- build_remove_item(target_class, collection_name, type)
48
+ build_add_item(target_class, collection_name, type, through)
49
+ build_remove_item(target_class, collection_name, type, through)
45
50
  build_bulk_add(target_class, collection_name, type)
46
51
 
47
52
  # Type-specific methods
@@ -74,11 +79,11 @@ module Familia
74
79
  end
75
80
 
76
81
  # Build method to add an item to the collection
77
- # Creates: customer.add_domains_instance(domain, score)
78
- def self.build_add_item(target_class, collection_name, type)
82
+ # Creates: customer.add_domains_instance(domain, score, through_attrs: {})
83
+ def self.build_add_item(target_class, collection_name, type, through = nil)
79
84
  method_name = "add_#{collection_name}_instance"
80
85
 
81
- target_class.define_method(method_name) do |item, score = nil|
86
+ target_class.define_method(method_name) do |item, score = nil, through_attrs: {}|
82
87
  collection = send(collection_name)
83
88
 
84
89
  # Calculate score if needed and not provided
@@ -86,6 +91,9 @@ module Familia
86
91
  score = item.calculate_participation_score(self.class, collection_name)
87
92
  end
88
93
 
94
+ # Resolve through class if specified
95
+ through_class = through ? Familia.resolve_class(through) : nil
96
+
89
97
  # Use transaction for atomicity between collection add and reverse index tracking
90
98
  # All operations use Horreum's DataType methods (not direct Redis calls)
91
99
  transaction do |_tx|
@@ -102,17 +110,42 @@ module Familia
102
110
  # Track participation in reverse index using DataType method (SADD)
103
111
  item.track_participation_in(collection.dbkey) if item.respond_to?(:track_participation_in)
104
112
  end
113
+
114
+ # TRANSACTION BOUNDARY: Through model operations intentionally happen AFTER
115
+ # the transaction block closes. This is a deliberate design decision because:
116
+ #
117
+ # 1. ThroughModelOperations.find_or_create performs load operations that would
118
+ # return Redis::Future objects inside a transaction, breaking the flow
119
+ # 2. The core participation (collection add + tracking) is atomic within the tx
120
+ # 3. Through model creation is logically separate - if it fails, the participation
121
+ # itself succeeded and can be cleaned up or retried independently
122
+ #
123
+ # If Familia's transaction handling changes in the future, revisit this boundary.
124
+ through_model = if through_class
125
+ Participation::ThroughModelOperations.find_or_create(
126
+ through_class: through_class,
127
+ target: self,
128
+ participant: item,
129
+ attrs: through_attrs
130
+ )
131
+ end
132
+
133
+ # Return through model if using :through, otherwise self for backward compat
134
+ through_model || self
105
135
  end
106
136
  end
107
137
 
108
138
  # Build method to remove an item from the collection
109
139
  # Creates: customer.remove_domains_instance(domain)
110
- def self.build_remove_item(target_class, collection_name, type)
140
+ def self.build_remove_item(target_class, collection_name, type, through = nil)
111
141
  method_name = "remove_#{collection_name}_instance"
112
142
 
113
143
  target_class.define_method(method_name) do |item|
114
144
  collection = send(collection_name)
115
145
 
146
+ # Resolve through class if specified
147
+ through_class = through ? Familia.resolve_class(through) : nil
148
+
116
149
  # Use transaction for atomicity between collection remove and reverse index untracking
117
150
  # All operations use Horreum's DataType methods (not direct Redis calls)
118
151
  transaction do |_tx|
@@ -122,6 +155,17 @@ module Familia
122
155
  # Remove from participation tracking using DataType method (SREM)
123
156
  item.untrack_participation_in(collection.dbkey) if item.respond_to?(:untrack_participation_in)
124
157
  end
158
+
159
+ # TRANSACTION BOUNDARY: Through model destruction intentionally happens AFTER
160
+ # the transaction block. See build_add_item for detailed rationale.
161
+ # The core removal is atomic; through model cleanup is a separate operation.
162
+ if through_class
163
+ Participation::ThroughModelOperations.find_and_destroy(
164
+ through_class: through_class,
165
+ target: self,
166
+ participant: item
167
+ )
168
+ end
125
169
  end
126
170
  end
127
171
 
@@ -0,0 +1,150 @@
1
+ # lib/familia/features/relationships/participation/through_model_operations.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ module Familia
6
+ module Features
7
+ module Relationships
8
+ module Participation
9
+ # ThroughModelOperations provides lifecycle management for through models
10
+ # in participation relationships.
11
+ #
12
+ # Through models implement the join table pattern, creating an intermediate
13
+ # object between target and participant that can carry additional attributes
14
+ # (e.g., role, permissions, metadata).
15
+ #
16
+ # Key characteristics:
17
+ # - Deterministic identifier: Built from target, participant, and through class
18
+ # - Auto-lifecycle: Created on add, destroyed on remove
19
+ # - Idempotent: Re-adding updates existing model
20
+ # - Atomic: All operations use transactions
21
+ # - Cache-friendly: Auto-updates updated_at for invalidation
22
+ #
23
+ # Example:
24
+ # class Membership < Familia::Horreum
25
+ # feature :object_identifier
26
+ # field :customer_objid
27
+ # field :domain_objid
28
+ # field :role
29
+ # field :updated_at
30
+ # end
31
+ #
32
+ # class Domain < Familia::Horreum
33
+ # participates_in Customer, :domains, through: :Membership
34
+ # end
35
+ #
36
+ # # Through model auto-created with deterministic key
37
+ # customer.add_domains_instance(domain, through_attrs: { role: 'admin' })
38
+ # # => #<Membership objid="customer:123:domain:456:membership">
39
+ #
40
+ module ThroughModelOperations
41
+ module_function
42
+
43
+ # Build a deterministic key for the through model
44
+ #
45
+ # The key format ensures uniqueness and allows direct lookup:
46
+ # {target.prefix}:{target.objid}:{participant.prefix}:{participant.objid}:{through.prefix}
47
+ #
48
+ # @param target [Object] The target instance (e.g., customer)
49
+ # @param participant [Object] The participant instance (e.g., domain)
50
+ # @param through_class [Class] The through model class
51
+ # @return [String] Deterministic key for the through model
52
+ #
53
+ def build_key(target:, participant:, through_class:)
54
+ "#{target.class.config_name}:#{target.objid}:" \
55
+ "#{participant.class.config_name}:#{participant.objid}:" \
56
+ "#{through_class.config_name}"
57
+ end
58
+
59
+ # Find or create a through model instance
60
+ #
61
+ # This method is idempotent - calling it multiple times with the same
62
+ # target/participant pair will update the existing through model rather
63
+ # than creating duplicates.
64
+ #
65
+ # The through model's updated_at is set on both create and update for
66
+ # cache invalidation.
67
+ #
68
+ # @param through_class [Class] The through model class
69
+ # @param target [Object] The target instance
70
+ # @param participant [Object] The participant instance
71
+ # @param attrs [Hash] Additional attributes to set on through model
72
+ # @return [Object] The created or updated through model instance
73
+ #
74
+ def find_or_create(through_class:, target:, participant:, attrs: {})
75
+ key = build_key(target: target, participant: participant, through_class: through_class)
76
+
77
+ # Try to load existing model - load returns nil if key doesn't exist
78
+ existing = through_class.load(key)
79
+
80
+ # Check if we got a valid loaded object using the public API
81
+ # This is called outside transaction boundaries (see participant_methods.rb
82
+ # and target_methods.rb for the transaction boundary documentation)
83
+ if existing&.exists?
84
+ # Update existing through model with validated attributes
85
+ safe_attrs = validated_attrs(through_class, attrs)
86
+ safe_attrs.each { |k, v| existing.send("#{k}=", v) }
87
+ existing.updated_at = Familia.now.to_f if existing.respond_to?(:updated_at=)
88
+ # Save returns boolean, but we want to return the model instance
89
+ existing.save if safe_attrs.any? || existing.respond_to?(:updated_at=)
90
+ existing # Return the model, not the save result
91
+ else
92
+ # Create new through model with our deterministic key as objid
93
+ # Pass objid during initialization to prevent auto-generation
94
+ inst = through_class.new(objid: key)
95
+
96
+ # Set foreign key fields if they exist (validated via respond_to?)
97
+ target_field = "#{target.class.config_name}_objid"
98
+ participant_field = "#{participant.class.config_name}_objid"
99
+ inst.send("#{target_field}=", target.objid) if inst.respond_to?("#{target_field}=")
100
+ inst.send("#{participant_field}=", participant.objid) if inst.respond_to?("#{participant_field}=")
101
+
102
+ # Set updated_at for cache invalidation
103
+ inst.updated_at = Familia.now.to_f if inst.respond_to?(:updated_at=)
104
+
105
+ # Set custom attributes (validated against field schema)
106
+ safe_attrs = validated_attrs(through_class, attrs)
107
+ safe_attrs.each { |k, v| inst.send("#{k}=", v) }
108
+
109
+ # Save returns boolean, but we want to return the model instance
110
+ inst.save
111
+ inst # Return the model, not the save result
112
+ end
113
+ end
114
+
115
+ # Find and destroy a through model instance
116
+ #
117
+ # Used during remove operations to clean up the join table entry.
118
+ #
119
+ # @param through_class [Class] The through model class
120
+ # @param target [Object] The target instance
121
+ # @param participant [Object] The participant instance
122
+ # @return [void]
123
+ #
124
+ def find_and_destroy(through_class:, target:, participant:)
125
+ key = build_key(target: target, participant: participant, through_class: through_class)
126
+ existing = through_class.load(key)
127
+ # Use the public exists? method for a more robust check
128
+ existing&.destroy! if existing&.exists?
129
+ end
130
+
131
+ # Validate attribute keys against the through model's field schema
132
+ #
133
+ # This prevents arbitrary method invocation by ensuring only defined
134
+ # fields can be set via the attrs hash.
135
+ #
136
+ # @param through_class [Class] The through model class
137
+ # @param attrs [Hash] Attributes to validate
138
+ # @return [Hash] Only attributes whose keys match defined fields
139
+ #
140
+ def validated_attrs(through_class, attrs)
141
+ return {} if attrs.nil? || attrs.empty?
142
+
143
+ valid_fields = through_class.fields.map(&:to_sym)
144
+ attrs.select { |k, _v| valid_fields.include?(k.to_sym) }
145
+ end
146
+ end
147
+ end
148
+ end
149
+ end
150
+ end