familia 2.0.0.pre22 → 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/CHANGELOG.rst +33 -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/features/relationships/README.md +1 -1
- 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/version.rb +1 -1
- data/pr_agent.toml +6 -1
- 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
- metadata +6 -2
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# examples/through_relationships.rb
|
|
3
|
+
#
|
|
4
|
+
# frozen_string_literal: true
|
|
5
|
+
|
|
6
|
+
# Through Relationships Example
|
|
7
|
+
# Demonstrates the :through option for participates_in, which creates
|
|
8
|
+
# intermediate join models with additional attributes (roles, metadata, etc.)
|
|
9
|
+
|
|
10
|
+
$LOAD_PATH.unshift File.expand_path('../lib', __dir__)
|
|
11
|
+
require 'familia'
|
|
12
|
+
|
|
13
|
+
# Configure Familia for the example
|
|
14
|
+
# Use port 2525 (Familia test database) or set REDIS_URL
|
|
15
|
+
Familia.configure do |config|
|
|
16
|
+
config.uri = ENV.fetch('REDIS_URL', 'redis://localhost:2525/')
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
puts '=== Familia Through Relationships Example ==='
|
|
20
|
+
puts
|
|
21
|
+
puts 'The :through option creates join models between participants and targets,'
|
|
22
|
+
puts 'similar to has_many :through in ActiveRecord but for Redis.'
|
|
23
|
+
puts
|
|
24
|
+
|
|
25
|
+
# ============================================================================
|
|
26
|
+
# MODEL DEFINITIONS
|
|
27
|
+
# ============================================================================
|
|
28
|
+
|
|
29
|
+
# The through model MUST use feature :object_identifier
|
|
30
|
+
# This enables deterministic key generation for the join record
|
|
31
|
+
class Membership < Familia::Horreum
|
|
32
|
+
logical_database 15
|
|
33
|
+
feature :object_identifier # REQUIRED for through models
|
|
34
|
+
feature :relationships
|
|
35
|
+
|
|
36
|
+
identifier_field :objid
|
|
37
|
+
|
|
38
|
+
# Foreign keys (auto-set by through operations)
|
|
39
|
+
field :organization_objid
|
|
40
|
+
field :user_objid
|
|
41
|
+
|
|
42
|
+
# Additional attributes - this is why we use :through!
|
|
43
|
+
field :role # 'owner', 'admin', 'member'
|
|
44
|
+
field :permissions # JSON or comma-separated list
|
|
45
|
+
field :invited_by # Who invited this user
|
|
46
|
+
field :invited_at # When they were invited
|
|
47
|
+
field :joined_at # When they accepted
|
|
48
|
+
field :updated_at # Auto-set for cache invalidation
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
class Organization < Familia::Horreum
|
|
52
|
+
logical_database 15
|
|
53
|
+
feature :object_identifier
|
|
54
|
+
feature :relationships
|
|
55
|
+
|
|
56
|
+
identifier_field :objid # Use objid as identifier (auto-generated)
|
|
57
|
+
field :name
|
|
58
|
+
field :plan
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
class User < Familia::Horreum
|
|
62
|
+
logical_database 15
|
|
63
|
+
feature :object_identifier
|
|
64
|
+
feature :relationships
|
|
65
|
+
|
|
66
|
+
identifier_field :objid # Use objid as identifier (auto-generated)
|
|
67
|
+
field :email
|
|
68
|
+
field :name
|
|
69
|
+
|
|
70
|
+
# Declare participation WITH a through model
|
|
71
|
+
# This creates Membership records when adding users to organizations
|
|
72
|
+
participates_in Organization, :members,
|
|
73
|
+
score: -> { Familia.now.to_f },
|
|
74
|
+
through: :Membership
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
puts '=== 1. Model Setup ==='
|
|
78
|
+
puts
|
|
79
|
+
puts 'Through model (Membership) requirements:'
|
|
80
|
+
puts ' • feature :object_identifier - enables deterministic keys'
|
|
81
|
+
puts ' • Fields for foreign keys: organization_objid, user_objid'
|
|
82
|
+
puts ' • Additional fields: role, permissions, invited_by, etc.'
|
|
83
|
+
puts
|
|
84
|
+
puts 'Participant (User) declaration:'
|
|
85
|
+
puts ' participates_in Organization, :members,'
|
|
86
|
+
puts ' score: -> { Familia.now.to_f },'
|
|
87
|
+
puts ' through: :Membership'
|
|
88
|
+
puts
|
|
89
|
+
|
|
90
|
+
# ============================================================================
|
|
91
|
+
# CREATING OBJECTS
|
|
92
|
+
# ============================================================================
|
|
93
|
+
|
|
94
|
+
puts '=== 2. Creating Objects ==='
|
|
95
|
+
|
|
96
|
+
org = Organization.new(name: 'Acme Corp', plan: 'enterprise')
|
|
97
|
+
org.save
|
|
98
|
+
puts "Created organization: #{org.name} (#{org.objid})"
|
|
99
|
+
|
|
100
|
+
alice = User.new(email: 'alice@acme.com', name: 'Alice')
|
|
101
|
+
alice.save
|
|
102
|
+
puts "Created user: #{alice.name} (#{alice.objid})"
|
|
103
|
+
|
|
104
|
+
bob = User.new(email: 'bob@acme.com', name: 'Bob')
|
|
105
|
+
bob.save
|
|
106
|
+
puts "Created user: #{bob.name} (#{bob.objid})"
|
|
107
|
+
|
|
108
|
+
charlie = User.new(email: 'charlie@acme.com', name: 'Charlie')
|
|
109
|
+
charlie.save
|
|
110
|
+
puts "Created user: #{charlie.name} (#{charlie.objid})"
|
|
111
|
+
puts
|
|
112
|
+
|
|
113
|
+
# ============================================================================
|
|
114
|
+
# ADDING MEMBERS WITH THROUGH ATTRIBUTES
|
|
115
|
+
# ============================================================================
|
|
116
|
+
|
|
117
|
+
puts '=== 3. Adding Members with Roles ==='
|
|
118
|
+
puts
|
|
119
|
+
|
|
120
|
+
# Add Alice as owner - through_attrs sets Membership fields
|
|
121
|
+
membership_alice = org.add_members_instance(alice, through_attrs: {
|
|
122
|
+
role: 'owner',
|
|
123
|
+
permissions: 'all',
|
|
124
|
+
joined_at: Familia.now.to_f,
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
puts "Added #{alice.name} as #{membership_alice.role}"
|
|
128
|
+
puts " Membership objid: #{membership_alice.objid}"
|
|
129
|
+
puts " Membership class: #{membership_alice.class.name}"
|
|
130
|
+
puts
|
|
131
|
+
|
|
132
|
+
# Add Bob as admin
|
|
133
|
+
membership_bob = org.add_members_instance(bob, through_attrs: {
|
|
134
|
+
role: 'admin',
|
|
135
|
+
permissions: 'read,write,invite',
|
|
136
|
+
invited_by: alice.objid,
|
|
137
|
+
invited_at: Familia.now.to_f,
|
|
138
|
+
joined_at: Familia.now.to_f,
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
puts "Added #{bob.name} as #{membership_bob.role}"
|
|
142
|
+
puts " Invited by: #{membership_bob.invited_by}"
|
|
143
|
+
puts
|
|
144
|
+
|
|
145
|
+
# Add Charlie as member
|
|
146
|
+
membership_charlie = org.add_members_instance(charlie, through_attrs: {
|
|
147
|
+
role: 'member',
|
|
148
|
+
permissions: 'read',
|
|
149
|
+
invited_by: bob.objid,
|
|
150
|
+
invited_at: Familia.now.to_f,
|
|
151
|
+
joined_at: Familia.now.to_f,
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
puts "Added #{charlie.name} as #{membership_charlie.role}"
|
|
155
|
+
puts
|
|
156
|
+
|
|
157
|
+
# ============================================================================
|
|
158
|
+
# QUERYING MEMBERSHIPS
|
|
159
|
+
# ============================================================================
|
|
160
|
+
|
|
161
|
+
puts '=== 4. Querying Memberships ==='
|
|
162
|
+
puts
|
|
163
|
+
|
|
164
|
+
# Check organization members
|
|
165
|
+
puts "Organization #{org.name} has #{org.members.size} members:"
|
|
166
|
+
org.members.members.each do |user_id|
|
|
167
|
+
puts " - #{user_id}"
|
|
168
|
+
end
|
|
169
|
+
puts
|
|
170
|
+
|
|
171
|
+
# Check if user is member
|
|
172
|
+
puts "Is Alice a member? #{alice.in_organization_members?(org)}"
|
|
173
|
+
puts "Is Bob a member? #{bob.in_organization_members?(org)}"
|
|
174
|
+
puts
|
|
175
|
+
|
|
176
|
+
# Load and inspect through model directly
|
|
177
|
+
# Key format: {target_prefix}:{target_objid}:{participant_prefix}:{participant_objid}:{through_prefix}
|
|
178
|
+
membership_key = "organization:#{org.objid}:user:#{alice.objid}:membership"
|
|
179
|
+
loaded_membership = Membership.load(membership_key)
|
|
180
|
+
|
|
181
|
+
puts 'Direct membership lookup for Alice:'
|
|
182
|
+
puts " Key: #{membership_key}"
|
|
183
|
+
puts " Role: #{loaded_membership.role}"
|
|
184
|
+
puts " Permissions: #{loaded_membership.permissions}"
|
|
185
|
+
puts " Updated at: #{Time.at(loaded_membership.updated_at.to_f)}"
|
|
186
|
+
puts
|
|
187
|
+
|
|
188
|
+
# ============================================================================
|
|
189
|
+
# UPDATING MEMBERSHIP ATTRIBUTES
|
|
190
|
+
# ============================================================================
|
|
191
|
+
|
|
192
|
+
puts '=== 5. Updating Membership (Idempotent) ==='
|
|
193
|
+
puts
|
|
194
|
+
|
|
195
|
+
# Re-adding with different attrs updates the existing membership
|
|
196
|
+
puts "Promoting #{charlie.name} from member to admin..."
|
|
197
|
+
|
|
198
|
+
updated_membership = org.add_members_instance(charlie, through_attrs: {
|
|
199
|
+
role: 'admin',
|
|
200
|
+
permissions: 'read,write',
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
puts " New role: #{updated_membership.role}"
|
|
204
|
+
puts " Same objid? #{updated_membership.objid == membership_charlie.objid}"
|
|
205
|
+
puts ' (Idempotent: updates existing, no duplicates)'
|
|
206
|
+
puts
|
|
207
|
+
|
|
208
|
+
# ============================================================================
|
|
209
|
+
# REMOVING MEMBERS
|
|
210
|
+
# ============================================================================
|
|
211
|
+
|
|
212
|
+
puts '=== 6. Removing Members ==='
|
|
213
|
+
puts
|
|
214
|
+
|
|
215
|
+
puts "Removing #{charlie.name} from organization..."
|
|
216
|
+
org.remove_members_instance(charlie)
|
|
217
|
+
|
|
218
|
+
# Verify removal
|
|
219
|
+
puts " Is Charlie still a member? #{charlie.in_organization_members?(org)}"
|
|
220
|
+
|
|
221
|
+
# Through model is also destroyed
|
|
222
|
+
removed_key = "organization:#{org.objid}:user:#{charlie.objid}:membership"
|
|
223
|
+
removed_membership = Membership.load(removed_key)
|
|
224
|
+
puts " Membership record exists? #{removed_membership&.exists? || false}"
|
|
225
|
+
puts
|
|
226
|
+
|
|
227
|
+
# ============================================================================
|
|
228
|
+
# BACKWARD COMPATIBILITY
|
|
229
|
+
# ============================================================================
|
|
230
|
+
|
|
231
|
+
puts '=== 7. Backward Compatibility ==='
|
|
232
|
+
puts
|
|
233
|
+
|
|
234
|
+
# Define a relationship WITHOUT :through
|
|
235
|
+
class Project < Familia::Horreum
|
|
236
|
+
logical_database 15
|
|
237
|
+
feature :object_identifier
|
|
238
|
+
feature :relationships
|
|
239
|
+
identifier_field :objid
|
|
240
|
+
field :name
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
class Task < Familia::Horreum
|
|
244
|
+
logical_database 15
|
|
245
|
+
feature :object_identifier
|
|
246
|
+
feature :relationships
|
|
247
|
+
identifier_field :objid
|
|
248
|
+
field :title
|
|
249
|
+
# No :through - works exactly as before
|
|
250
|
+
participates_in Project, :tasks, score: -> { Familia.now.to_f }
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
project = Project.new(name: 'Website Redesign')
|
|
254
|
+
project.save
|
|
255
|
+
|
|
256
|
+
task = Task.new(title: 'Design mockups')
|
|
257
|
+
task.save
|
|
258
|
+
|
|
259
|
+
# Without :through, returns the target (not a through model)
|
|
260
|
+
result = project.add_tasks_instance(task)
|
|
261
|
+
puts "Without :through, add returns: #{result.class.name}"
|
|
262
|
+
puts ' (Returns the target, not a through model)'
|
|
263
|
+
puts
|
|
264
|
+
|
|
265
|
+
# ============================================================================
|
|
266
|
+
# CLEANUP
|
|
267
|
+
# ============================================================================
|
|
268
|
+
|
|
269
|
+
puts '=== 8. Cleanup ==='
|
|
270
|
+
|
|
271
|
+
[org, alice, bob, charlie, project, task].each do |obj|
|
|
272
|
+
obj.destroy! if obj&.exists?
|
|
273
|
+
end
|
|
274
|
+
puts 'Cleaned up all test objects'
|
|
275
|
+
puts
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
**participates_in** - Collection membership ("this object belongs in that collection")
|
|
14
14
|
```ruby
|
|
15
|
-
participates_in Organization, :members, score: :joined_at,
|
|
15
|
+
participates_in Organization, :members, score: :joined_at, generate_participant_methods: true
|
|
16
16
|
# Creates: org.members, org.add_member(), customer.add_to_organization_members()
|
|
17
17
|
```
|
|
18
18
|
|
|
@@ -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
|
|