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



42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
# File 'lib/familia/features/relationships/participation/target_methods.rb', line 42

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

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

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

  build_permission_query(target_class, collection_name)
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: {})



83
84
85
86
87
88
89
90
91
92
93
94
95
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
# File 'lib/familia/features/relationships/participation/target_methods.rb', line 83

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



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

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_class_add_method(target_class, collection_name, type) ⇒ Object

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



227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
# File 'lib/familia/features/relationships/participation/target_methods.rb', line 227

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)



218
219
220
221
222
223
# File 'lib/familia/features/relationships/participation/target_methods.rb', line 218

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



62
63
64
65
66
67
68
69
70
# File 'lib/familia/features/relationships/participation/target_methods.rb', line 62

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)



257
258
259
260
261
262
263
264
# File 'lib/familia/features/relationships/participation/target_methods.rb', line 257

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



74
75
76
77
78
79
# File 'lib/familia/features/relationships/participation/target_methods.rb', line 74

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)



199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
# File 'lib/familia/features/relationships/participation/target_methods.rb', line 199

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)



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
168
169
170
# File 'lib/familia/features/relationships/participation/target_methods.rb', line 140

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.
    if through_class
      Participation::ThroughModelOperations.find_and_destroy(
        through_class: through_class,
        target: self,
        participant: item
      )
    end
  end
end