Module: Familia::Features::Relationships::TargetMethods::Builder

Extended by:
CollectionOperations, Participation::ThroughModelOperations
Defined in:
lib/familia/features/relationships/participation/target_methods.rb

Overview

Visual Guide for methods added to TARGET instances:

When Domain calls: participates_in Customer, :domains

Customer instances (TARGET) get these methods: ├── domains # Get the domains collection ├── add_domain(domain, score) # Add a domain to my collection ├── remove_domain(domain) # Remove a domain from my collection ├── add_domains([...]) # Bulk add domains └── domains_with_permission(level) # Query with score filtering (sorted_set only)

Class Method Summary collapse

Methods included from CollectionOperations

add_to_collection, bulk_add_to_collection, ensure_collection_field, member_of_collection?, remove_from_collection

Methods included from Participation::ThroughModelOperations

build_key, find_and_destroy, find_or_create, validated_attrs

Class Method Details

.build(target_class, collection_name, type, through = nil, staged = nil) ⇒ Object

Build all target methods for a participation relationship

Parameters:

  • target_class (Class)

    The class receiving these methods (e.g., Customer)

  • collection_name (Symbol)

    Name of the collection (e.g., :domains)

  • type (Symbol)

    Collection type (:sorted_set, :set, :list)

  • through (Symbol, Class, nil) (defaults to: nil)

    Through model class for join table pattern

  • staged (Symbol, nil) (defaults to: nil)

    Staging set name for deferred activation



43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
# File 'lib/familia/features/relationships/participation/target_methods.rb', line 43

def self.build(target_class, collection_name, type, through = nil, staged = nil)
  # FIRST: Ensure the DataType field is defined on the target class
  TargetMethods::Builder.ensure_collection_field(target_class, collection_name, type)

  # Create staging set if staged: option provided
  TargetMethods::Builder.ensure_collection_field(target_class, staged, :sorted_set) if staged

  # Core target methods
  build_collection_getter(target_class, collection_name, type)
  build_add_item(target_class, collection_name, type, through)
  build_remove_item(target_class, collection_name, type, through)
  build_bulk_add(target_class, collection_name, type)

  # Staged relationship methods (requires through model)
  if staged && through
    build_stage_method(target_class, collection_name, staged, through)
    build_activate_method(target_class, collection_name, staged, through)
    build_unstage_method(target_class, collection_name, staged, through)
    build_bulk_stage_method(target_class, collection_name, staged, through)
    build_bulk_unstage_method(target_class, collection_name, staged, through)
  end

  # Type-specific methods
  return unless type == :sorted_set

  build_permission_query(target_class, collection_name)
end

.build_activate_method(target_class, collection_name, staged_name, through) ⇒ Object

Build method to activate a staged through model Creates: org.activate_members_instance(staged_model, participant, through_attrs: {})

Activation completes the relationship:

  • ZADD to active collection with participant
  • SADD to participant's reverse index
  • ZREM from staging collection
  • Create composite-keyed through model
  • Destroy UUID-keyed staged model

Parameters:

  • target_class (Class)

    The target class

  • collection_name (Symbol)

    Active collection name

  • staged_name (Symbol)

    Staging collection name

  • through (Symbol, Class)

    Through model class



274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
# File 'lib/familia/features/relationships/participation/target_methods.rb', line 274

def self.build_activate_method(target_class, collection_name, staged_name, through)
  method_name = "activate_#{collection_name}_instance"

  target_class.define_method(method_name) do |staged_model, participant, through_attrs: {}|
    through_class = Familia.resolve_class(through)
    active_collection = send(collection_name)
    staging_collection = send(staged_name)

    # Calculate score for participant in active set
    score = if participant.respond_to?(:calculate_participation_score)
      participant.calculate_participation_score(self.class, collection_name)
    else
      Familia.now.to_f
    end

    # Transaction: sorted set operations (ZADD active + SADD participations + ZREM staging)
    transaction do |_tx|
      # Add to active collection
      TargetMethods::Builder.add_to_collection(
        active_collection,
        participant,
        score: score,
        type: :sorted_set,
        target_class: self.class,
        collection_name: collection_name,
      )

      # Track participation in reverse index
      if participant.respond_to?(:track_participation_in)
        participant.track_participation_in(active_collection.dbkey)
      end

      # Remove from staging set and log warning if entry not found
      removed = staging_collection.remove(staged_model.objid)
      Familia.debug "[activate] Staging entry not found for #{staged_model.objid}" if removed == 0
    end

    # TRANSACTION BOUNDARY: Through model operations happen outside transaction
    # (same pattern as build_add_item - see that method for detailed rationale)
    Participation::StagedOperations.activate(
      through_class: through_class,
      staged_model: staged_model,
      target: self,
      participant: participant,
      attrs: through_attrs,
    )
  end
end

.build_add_item(target_class, collection_name, type, through = nil) ⇒ Object

Build method to add an item to the collection Creates: customer.add_domains_instance(domain, score, through_attrs: {})



96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
# File 'lib/familia/features/relationships/participation/target_methods.rb', line 96

def self.build_add_item(target_class, collection_name, type, through = nil)
  method_name = "add_#{collection_name}_instance"

  target_class.define_method(method_name) do |item, score = nil, through_attrs: {}|
    collection = send(collection_name)

    # Calculate score if needed and not provided
    if type == :sorted_set && score.nil? && item.respond_to?(:calculate_participation_score)
      score = item.calculate_participation_score(self.class, collection_name)
    end

    # Resolve through class if specified
    through_class = through ? Familia.resolve_class(through) : nil

    # Use transaction for atomicity between collection add and reverse index tracking
    # All operations use Horreum's DataType methods (not direct Redis calls)
    transaction do |_tx|
      # Add to collection using DataType method (ZADD/SADD/RPUSH)
      TargetMethods::Builder.add_to_collection(
        collection,
        item,
        score: score,
        type: type,
        target_class: self.class,
        collection_name: collection_name,
      )

      # Track participation in reverse index using DataType method (SADD)
      item.track_participation_in(collection.dbkey) if item.respond_to?(:track_participation_in)
    end

    # TRANSACTION BOUNDARY: Through model operations intentionally happen AFTER
    # the transaction block closes. This is a deliberate design decision because:
    #
    # 1. ThroughModelOperations.find_or_create performs load operations that would
    #    return Redis::Future objects inside a transaction, breaking the flow
    # 2. The core participation (collection add + tracking) is atomic within the tx
    # 3. Through model creation is logically separate - if it fails, the participation
    #    itself succeeded and can be cleaned up or retried independently
    #
    # If Familia's transaction handling changes in the future, revisit this boundary.
    through_model = if through_class
      Participation::ThroughModelOperations.find_or_create(
        through_class: through_class,
        target: self,
        participant: item,
        attrs: through_attrs,
      )
    end

    # Return through model if using :through, otherwise self for backward compat
    through_model || self
  end
end

.build_bulk_add(target_class, collection_name, type) ⇒ Object

Build method for bulk adding items Creates: customer.add_domains([domain1, domain2, ...])



187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
# File 'lib/familia/features/relationships/participation/target_methods.rb', line 187

def self.build_bulk_add(target_class, collection_name, type)
  method_name = "add_#{collection_name}"

  target_class.define_method(method_name) do |items|
    return if items.empty?

    collection = send(collection_name)

    # Use transaction for atomicity across all bulk additions and reverse index tracking
    # All operations use Horreum's DataType methods (not direct Redis calls)
    transaction do |_tx|
      # Bulk add to collection using DataType methods (multiple ZADD/SADD/RPUSH)
      TargetMethods::Builder.bulk_add_to_collection(collection, items, type: type, target_class: self.class,
collection_name: collection_name)

      # Track all participations using DataType methods (multiple SADD)
      items.each do |item|
        item.track_participation_in(collection.dbkey) if item.respond_to?(:track_participation_in)
      end
    end
  end
end

.build_bulk_stage_method(target_class, collection_name, staged_name, through) ⇒ Object

Build method to bulk stage multiple through models Creates: org.stage_members(through_attrs_list)

Stages multiple invitations at once. Each entry in the list creates a UUID-keyed through model and adds it to the staging set.

Uses two-phase approach for efficiency:

  • Phase 1: Create through models sequentially (save requires inspectable returns)
  • Phase 2: Pipeline all ZADD calls (reduces N round-trips to 1)

Parameters:

  • target_class (Class)

    The target class

  • collection_name (Symbol)

    Active collection name (for method naming)

  • staged_name (Symbol)

    Staging collection name

  • through (Symbol, Class)

    Through model class



362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
# File 'lib/familia/features/relationships/participation/target_methods.rb', line 362

def self.build_bulk_stage_method(target_class, collection_name, staged_name, through)
  method_name = "stage_#{collection_name}"

  target_class.define_method(method_name) do |through_attrs_list|
    return [] if through_attrs_list.empty?

    through_class = Familia.resolve_class(through)
    staging_collection = send(staged_name)

    # Phase 1: Create through models sequentially (save requires inspectable returns)
    staged_models = through_attrs_list.map do |attrs|
      Participation::StagedOperations.stage(
        through_class: through_class,
        target: self,
        attrs: attrs,
      )
    end

    # Phase 2: Pipeline all ZADD calls (reduces N round-trips to 1)
    pipelined do |_pipe|
      now = Familia.now.to_f
      staged_models.each { |m| staging_collection.add(m.objid, now) }
    end

    staged_models
  end
end

.build_bulk_unstage_method(target_class, collection_name, staged_name, through) ⇒ Object

Build method to bulk unstage multiple through models Creates: org.unstage_members(staged_models_or_objids)

Revokes multiple invitations at once. Accepts either staged model objects or their objids (flexible). Returns count of models destroyed.

Uses two-phase approach for efficiency:

  • Phase 1: Pipeline all ZREM calls (reduces N round-trips to 1)
  • Phase 2: Destroy models sequentially (load/exists?/destroy! need inspectable returns)

Parameters:

  • target_class (Class)

    The target class

  • collection_name (Symbol)

    Active collection name (for method naming)

  • staged_name (Symbol)

    Staging collection name

  • through (Symbol, Class)

    Through model class



404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
# File 'lib/familia/features/relationships/participation/target_methods.rb', line 404

def self.build_bulk_unstage_method(target_class, collection_name, staged_name, through)
  method_name = "unstage_#{collection_name}"

  target_class.define_method(method_name) do |staged_models_or_objids|
    return 0 if staged_models_or_objids.empty?

    through_class = Familia.resolve_class(through)
    staging_collection = send(staged_name)

    # Phase 1: Pipeline all ZREM calls (reduces N round-trips to 1)
    pipelined do |_pipe|
      staged_models_or_objids.each do |item|
        objid = item.respond_to?(:objid) ? item.objid : item
        staging_collection.remove(objid)
      end
    end

    # Phase 2: Destroy through models sequentially
    # StagedOperations.unstage returns true on success, false if model didn't exist
    staged_models_or_objids.count do |item|
      model = if item.respond_to?(:exists?)
        item
      else
        through_class.load(item.respond_to?(:objid) ? item.objid : item)
      end
      Participation::StagedOperations.unstage(staged_model: model) if model
    end
  end
end

.build_class_add_method(target_class, collection_name, type) ⇒ Object

Build class-level add method Creates: User.add_to_all_users(user, score)



445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
# File 'lib/familia/features/relationships/participation/target_methods.rb', line 445

def self.build_class_add_method(target_class, collection_name, type)
  method_name = "add_to_#{collection_name}"

  target_class.define_singleton_method(method_name) do |item, score = nil|
    collection = send(collection_name.to_s)

    # Calculate score if needed
    if type == :sorted_set && score.nil?
      score = if item.respond_to?(:calculate_participation_score)
        item.calculate_participation_score('class', collection_name)
      elsif item.respond_to?(:current_score)
        item.current_score
      else
        Familia.now.to_f
      end
    end

    TargetMethods::Builder.add_to_collection(
      collection,
      item,
      score: score,
      type: type,
      target_class: self.class,
      collection_name: collection_name,
    )
  end
end

.build_class_collection_getter(target_class, collection_name, type) ⇒ Object

Build class-level collection getter Creates: User.all_users (class method)



436
437
438
439
440
441
# File 'lib/familia/features/relationships/participation/target_methods.rb', line 436

def self.build_class_collection_getter(target_class, collection_name, type)
  # No need to define the method - Horreum automatically creates it
  # when we call class_#{type} above. This method is kept for
  # backwards compatibility but now does nothing.
  # The field definition (class_sorted_set :all_users) creates the accessor automatically.
end

.build_class_level(target_class, collection_name, type) ⇒ Object

Build class-level collection methods (for class_participates_in)

Parameters:

  • target_class (Class)

    The class receiving these methods

  • collection_name (Symbol)

    Name of the collection

  • type (Symbol)

    Collection type



75
76
77
78
79
80
81
82
83
# File 'lib/familia/features/relationships/participation/target_methods.rb', line 75

def self.build_class_level(target_class, collection_name, type)
  # FIRST: Ensure the class-level DataType field is defined
  target_class.send("class_#{type}", collection_name)

  # Class-level collection getter (e.g., User.all_users)
  build_class_collection_getter(target_class, collection_name, type)
  build_class_add_method(target_class, collection_name, type)
  build_class_remove_method(target_class, collection_name)
end

.build_class_remove_method(target_class, collection_name) ⇒ Object

Build class-level remove method Creates: User.remove_from_all_users(user)



475
476
477
478
479
480
481
482
# File 'lib/familia/features/relationships/participation/target_methods.rb', line 475

def self.build_class_remove_method(target_class, collection_name)
  method_name = "remove_from_#{collection_name}"

  target_class.define_singleton_method(method_name) do |item|
    collection = send(collection_name.to_s)
    TargetMethods::Builder.remove_from_collection(collection, item)
  end
end

.build_collection_getter(target_class, collection_name, type) ⇒ Object

Build method to get the collection Creates: customer.domains



87
88
89
90
91
92
# File 'lib/familia/features/relationships/participation/target_methods.rb', line 87

def self.build_collection_getter(target_class, collection_name, type)
  # No need to define the method - Horreum automatically creates it
  # when we call ensure_collection_field above. This method is
  # kept for backwards compatibility but now does nothing.
  # The field definition (sorted_set :domains) creates the accessor automatically.
end

.build_permission_query(target_class, collection_name) ⇒ Object

Build permission query for sorted sets Creates: customer.domains_with_permission(min_level)



212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
# File 'lib/familia/features/relationships/participation/target_methods.rb', line 212

def self.build_permission_query(target_class, collection_name)
  method_name = "#{collection_name}_with_permission"

  target_class.define_method(method_name) do |min_permission = :read|
    collection = send(collection_name)

    # Assumes ScoreEncoding module is available
    if defined?(ScoreEncoding)
      permission_score = ScoreEncoding.permission_encode(0, min_permission)
      collection.zrangebyscore(permission_score, '+inf', with_scores: true)
    else
      # Fallback to all members if ScoreEncoding not available
      collection.members(with_scores: true)
    end
  end
end

.build_remove_item(target_class, collection_name, type, through = nil) ⇒ Object

Build method to remove an item from the collection Creates: customer.remove_domains_instance(domain)



153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
# File 'lib/familia/features/relationships/participation/target_methods.rb', line 153

def self.build_remove_item(target_class, collection_name, type, through = nil)
  method_name = "remove_#{collection_name}_instance"

  target_class.define_method(method_name) do |item|
    collection = send(collection_name)

    # Resolve through class if specified
    through_class = through ? Familia.resolve_class(through) : nil

    # Use transaction for atomicity between collection remove and reverse index untracking
    # All operations use Horreum's DataType methods (not direct Redis calls)
    transaction do |_tx|
      # Remove from collection using DataType method (ZREM/SREM/LREM)
      TargetMethods::Builder.remove_from_collection(collection, item, type: type)

      # Remove from participation tracking using DataType method (SREM)
      item.untrack_participation_in(collection.dbkey) if item.respond_to?(:untrack_participation_in)
    end

    # TRANSACTION BOUNDARY: Through model destruction intentionally happens AFTER
    # the transaction block. See build_add_item for detailed rationale.
    # The core removal is atomic; through model cleanup is a separate operation.
    return unless through_class

    Participation::ThroughModelOperations.find_and_destroy(
      through_class: through_class,
      target: self,
      participant: item,
    )
  end
end

.build_stage_method(target_class, collection_name, staged_name, through) ⇒ Object

Build method to stage a through model for deferred activation Creates: org.stage_members_instance(through_attrs: {})

Stage creates a UUID-keyed through model and adds it to the staging set. The participant doesn't exist yet (e.g., invitation sent but not accepted).

Parameters:

  • target_class (Class)

    The target class (e.g., Organization)

  • collection_name (Symbol)

    Active collection name (e.g., :members)

  • staged_name (Symbol)

    Staging collection name (e.g., :pending_members)

  • through (Symbol, Class)

    Through model class



239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
# File 'lib/familia/features/relationships/participation/target_methods.rb', line 239

def self.build_stage_method(target_class, collection_name, staged_name, through)
  method_name = "stage_#{collection_name}_instance"

  target_class.define_method(method_name) do |through_attrs: {}|
    through_class = Familia.resolve_class(through)
    staging_collection = send(staged_name)

    # Create UUID-keyed staged model
    staged_model = Participation::StagedOperations.stage(
      through_class: through_class,
      target: self,
      attrs: through_attrs,
    )

    # Add to staging set with created_at as score
    staging_collection.add(staged_model.objid, Familia.now.to_f)

    staged_model
  end
end

.build_unstage_method(target_class, collection_name, staged_name, _through) ⇒ Object

Build method to unstage (revoke) a staged through model Creates: org.unstage_members_instance(staged_model)

Unstaging removes the through model from staging and destroys it. Used when an invitation is revoked before acceptance.

Parameters:

  • target_class (Class)

    The target class

  • collection_name (Symbol)

    Active collection name (for method naming)

  • staged_name (Symbol)

    Staging collection name

  • _through (Symbol, Class)

    Through model class (unused - kept for signature consistency with other builders like build_stage_method and build_activate_method)



334
335
336
337
338
339
340
341
342
343
344
345
346
# File 'lib/familia/features/relationships/participation/target_methods.rb', line 334

def self.build_unstage_method(target_class, collection_name, staged_name, _through)
  method_name = "unstage_#{collection_name}_instance"

  target_class.define_method(method_name) do |staged_model|
    staging_collection = send(staged_name)

    # Remove from staging set
    staging_collection.remove(staged_model.objid)

    # Destroy the through model
    Participation::StagedOperations.unstage(staged_model: staged_model)
  end
end