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.
@@ -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, bidirectional: true
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
- # 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