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, participant_class: 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

  • participant_class (Class, nil) (defaults to: nil)

    The participant class whose identifiers populate the collection. Threaded through so the collection is declared with record_class: (enables +each_record+ without changing read semantics; see issue #297).



47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
# File 'lib/familia/features/relationships/participation/target_methods.rb', line 47

def self.build(target_class, collection_name, type, through = nil, staged = nil, participant_class: nil)
  # FIRST: Ensure the DataType field is defined on the target class.
  # Declared with record_class: so `each_record` can load participants.
  TargetMethods::Builder.ensure_collection_field(
    target_class, collection_name, type, participant_class: participant_class
  )

  # 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



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
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
# File 'lib/familia/features/relationships/participation/target_methods.rb', line 292

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: {})



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
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
# File 'lib/familia/features/relationships/participation/target_methods.rb', line 114

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, ...])



205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
# File 'lib/familia/features/relationships/participation/target_methods.rb', line 205

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



380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
# File 'lib/familia/features/relationships/participation/target_methods.rb', line 380

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



422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
# File 'lib/familia/features/relationships/participation/target_methods.rb', line 422

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)



463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
# File 'lib/familia/features/relationships/participation/target_methods.rb', line 463

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)



454
455
456
457
458
459
# File 'lib/familia/features/relationships/participation/target_methods.rb', line 454

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



82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
# File 'lib/familia/features/relationships/participation/target_methods.rb', line 82

def self.build_class_level(target_class, collection_name, type)
  # FIRST: Ensure the class-level DataType field is defined.
  # The collection holds instances of target_class itself. Declare it
  # with record_class: so `each_record` can load the records (issue
  # #297) without changing read deserialization — see
  # CollectionOperations#ensure_collection_field for why participation
  # uses record_class: rather than class: + reference: true.
  #
  # Skip if a class-level accessor already exists, mirroring the
  # method_defined? guard in ensure_collection_field so a pre-declared
  # collection is not silently overridden (symmetry with instance-level).
  unless target_class.respond_to?(collection_name)
    target_class.send("class_#{type}", collection_name, record_class: target_class)
  end

  # 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)



493
494
495
496
497
498
499
500
# File 'lib/familia/features/relationships/participation/target_methods.rb', line 493

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



105
106
107
108
109
110
# File 'lib/familia/features/relationships/participation/target_methods.rb', line 105

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)



230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
# File 'lib/familia/features/relationships/participation/target_methods.rb', line 230

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)



171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
# File 'lib/familia/features/relationships/participation/target_methods.rb', line 171

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



257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
# File 'lib/familia/features/relationships/participation/target_methods.rb', line 257

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)



352
353
354
355
356
357
358
359
360
361
362
363
364
# File 'lib/familia/features/relationships/participation/target_methods.rb', line 352

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