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.
- checksums.yaml +4 -4
- data/.github/workflows/claude-code-review.yml +8 -5
- data/.talismanrc +5 -1
- data/CHANGELOG.rst +76 -0
- data/Gemfile.lock +8 -8
- data/docs/1106-participates_in-bidirectional-solution.md +201 -58
- data/examples/through_relationships.rb +275 -0
- data/lib/familia/connection/operation_core.rb +1 -2
- data/lib/familia/connection/pipelined_core.rb +1 -3
- data/lib/familia/connection/transaction_core.rb +1 -2
- data/lib/familia/data_type/serialization.rb +76 -51
- data/lib/familia/data_type/types/sorted_set.rb +5 -10
- data/lib/familia/data_type/types/stringkey.rb +22 -0
- data/lib/familia/features/external_identifier.rb +29 -0
- data/lib/familia/features/object_identifier.rb +47 -0
- data/lib/familia/features/relationships/README.md +1 -1
- data/lib/familia/features/relationships/indexing/rebuild_strategies.rb +15 -15
- data/lib/familia/features/relationships/indexing/unique_index_generators.rb +8 -0
- data/lib/familia/features/relationships/participation/participant_methods.rb +59 -10
- data/lib/familia/features/relationships/participation/target_methods.rb +51 -7
- data/lib/familia/features/relationships/participation/through_model_operations.rb +150 -0
- data/lib/familia/features/relationships/participation.rb +39 -15
- data/lib/familia/features/relationships/participation_relationship.rb +19 -1
- data/lib/familia/features/relationships.rb +1 -1
- data/lib/familia/horreum/database_commands.rb +6 -1
- data/lib/familia/horreum/management.rb +141 -10
- data/lib/familia/horreum/persistence.rb +3 -0
- data/lib/familia/identifier_extractor.rb +1 -1
- data/lib/familia/version.rb +1 -1
- data/lib/multi_result.rb +59 -31
- data/pr_agent.toml +6 -1
- data/try/features/count_any_edge_cases_try.rb +486 -0
- data/try/features/count_any_methods_try.rb +197 -0
- data/try/features/external_identifier/external_identifier_try.rb +134 -0
- data/try/features/object_identifier/object_identifier_try.rb +138 -0
- data/try/features/relationships/indexing_rebuild_try.rb +6 -0
- data/try/features/relationships/participation_commands_verification_spec.rb +1 -1
- data/try/features/relationships/participation_commands_verification_try.rb +1 -1
- data/try/features/relationships/participation_method_prefix_try.rb +133 -0
- data/try/features/relationships/participation_reverse_index_try.rb +1 -1
- data/try/features/relationships/{participation_bidirectional_try.rb → participation_reverse_methods_try.rb} +6 -6
- data/try/features/relationships/participation_through_try.rb +173 -0
- data/try/integration/data_types/datatype_pipelines_try.rb +5 -3
- data/try/integration/data_types/datatype_transactions_try.rb +13 -7
- data/try/integration/models/customer_try.rb +3 -3
- data/try/unit/data_types/boolean_try.rb +35 -22
- data/try/unit/data_types/hash_try.rb +2 -2
- data/try/unit/data_types/serialization_try.rb +386 -0
- data/try/unit/horreum/destroy_related_fields_cleanup_try.rb +2 -1
- metadata +9 -8
- data/changelog.d/20251105_flexible_external_identifier_format.rst +0 -66
- data/changelog.d/20251107_112554_delano_179_participation_asymmetry.rst +0 -44
- data/changelog.d/20251107_213121_delano_fix_thread_safety_races_011CUumCP492Twxm4NLt2FvL.rst +0 -20
- data/changelog.d/20251107_fix_participates_in_symbol_resolution.rst +0 -91
- data/changelog.d/20251107_optimized_redis_exists_checks.rst +0 -94
- 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
|
|
108
|
-
instances.
|
|
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
|
|
121
|
-
tx.hset(temp_key, value.to_s, obj
|
|
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
|
|
226
|
-
collection.
|
|
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
|
|
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
|
|
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
|
|
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
|
-
#
|
|
77
|
-
#
|
|
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
|
|
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
|
-
|
|
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
|