Module: Familia::Features::Relationships::Participation::ModelClassMethods

Defined in:
lib/familia/features/relationships/participation.rb

Overview

Class methods for defining participation relationships.

These methods are available on any class that includes the Participation module, allowing definition of both instance-level and class-level participation relationships.

Instance Method Summary collapse

Instance Method Details

#class_participates_in(collection_name, score: nil, type: :sorted_set, generate_participant_methods: true, through: nil) ⇒ Object

Define a class-level participation collection where all instances automatically participate.

Class-level participation creates a global collection containing all instances of the class, with automatic management of membership based on object lifecycle events. This is useful for maintaining global indexes, leaderboards, or categorical groupings.

The collection is created at the class level (e.g., User.all_users) rather than on individual instances, providing a centralized view of all objects matching the criteria.

=== Generated Methods

==== On the Class (Target Methods)

  • +ClassName.collection_name+ - Access the collection DataType
  • +ClassName.add_to_collection_name(instance)+ - Add instance to collection
  • +ClassName.remove_from_collection_name(instance)+ - Remove instance from collection

==== On Instances (Participant Methods, if generate_participant_methods)

  • +instance.in_class_collection_name?+ - Check membership in class collection
  • +instance.add_to_class_collection_name+ - Add self to class collection
  • +instance.remove_from_class_collection_name+ - Remove self from class collection

Examples:

Simple priority-based global collection

class User < Familia::Horreum
  field :priority_level
  class_participates_in :all_users, score: :priority_level
end

User.all_users.first        # Highest priority user
user.in_class_all_users?    # true if user is in collection

Dynamic scoring based on status

class Customer < Familia::Horreum
  field :status
  field :last_purchase

  class_participates_in :active_customers, score: -> {
    status == 'active' ? last_purchase.to_i : 0
  }
end

Customer.active_customers.to_a  # All active customers, sorted by last purchase

Parameters:

  • collection_name (Symbol)

    Name of the class-level collection (e.g., +:all_users+, +:active_members+)

  • score (Symbol, Proc, Numeric, nil) (defaults to: nil)

    Scoring strategy for sorted collections:

    • +Symbol+: Field name or method name (e.g., +:priority_level+, +:created_at+)
    • +Proc+: Dynamic calculation in instance context (e.g., +-> { status == 'premium' ? 100 : 0 }+)
    • +Numeric+: Static score for all instances (e.g., +50.0+)
    • +nil+: Use +current_score+ method fallback
    • +:remove+: Remove from collection on destruction (default)
    • +:ignore+: Leave in collection when destroyed
  • type (Symbol) (defaults to: :sorted_set)

    Valkey/Redis collection type:

    • +:sorted_set+: Ordered by score (default)
    • +:set+: Unordered unique membership
    • +:list+: Ordered sequence allowing duplicates
  • generate_participant_methods (Boolean) (defaults to: true)

    Whether to generate convenience methods on instances (default: +true+)

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

    Optional join model class for storing additional attributes. See +participates_in+ for details.

See Also:

Since Version:

  • 1.0.0



165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
# File 'lib/familia/features/relationships/participation.rb', line 165

def class_participates_in(collection_name, score: nil,
                          type: :sorted_set, generate_participant_methods: true, through: nil)
  # Store metadata for this participation relationship
  participation_relationships << ParticipationRelationship.new(
    _original_target: self,   # For class-level, original and resolved are the same
    target_class: self,       # The class itself
    collection_name: collection_name,
    score: score,
    type: type,
    generate_participant_methods: generate_participant_methods,
    through: through,
    method_prefix: nil,       # Not applicable for class-level participation
  )

  # STEP 1: Add collection management methods to the class itself
  # e.g., User.all_users, User.add_to_all_users(user)
  TargetMethods::Builder.build_class_level(self, collection_name, type)

  # STEP 2: Add participation methods to instances (if generate_participant_methods)
  # e.g., user.in_class_all_users?, user.add_to_class_all_users
  return unless generate_participant_methods

  # Pass the string 'class' as target to distinguish class-level from instance-level
  # This prevents generating reverse collection methods (user can't have "all_users")
  # See ParticipantMethods::Builder.build for handling of this special case
  ParticipantMethods::Builder.build(self, 'class', collection_name, type, nil, through, nil)
end

#participates_in(target, collection_name, score: nil, type: :sorted_set, generate_participant_methods: true, as: nil, through: nil, method_prefix: nil) ⇒ Object

Define an instance-level participation relationship between two classes.

This method creates a bidirectional relationship where instances of the calling class (participants) can join collections owned by instances of the target class. This enables flexible multi-membership scenarios where objects can belong to multiple collections simultaneously with different scoring and management strategies.

The relationship automatically handles reverse index tracking, allowing efficient lookup of all collections a participant belongs to via the +current_participations+ method.

=== Generated Methods

==== On Target Class (Collection Owner)

  • +target.collection_name+ - Access the collection DataType
  • +target.add_participant_class_name(participant)+ - Add participant to collection
  • +target.remove_participant_class_name(participant)+ - Remove participant from collection
  • +target.add_participant_class_names([participants])+ - Bulk add multiple participants

==== On Participant Class (if generate_participant_methods)

  • +participant.in_target_collection_name?(target)+ - Check membership in target's collection
  • +participant.add_to_target_collection_name(target)+ - Add self to target's collection
  • +participant.remove_from_target_collection_name(target)+ - Remove self from target's collection

=== Reverse Index Tracking

Automatically creates a +:participations+ set field on the participant class to track all collections the instance belongs to. This enables efficient membership queries and cleanup operations without scanning all possible collections.

  • +Symbol+: Field name or method name (e.g., +:priority+, +:created_at+)
  • +Proc+: Dynamic calculation executed in participant instance context
  • +Numeric+: Static score applied to all participants
  • +nil+: Use +current_score+ method as fallback
  • +:remove+: Remove from all collections on destruction (default)
  • +:ignore+: Leave in collections when destroyed
  • Must use +feature :object_identifier+
  • Gets auto-created when adding to collection (via +through_attrs:+ param)
  • Gets auto-destroyed when removing from collection
  • Uses deterministic keys: +target:id:participant:id:through+

Examples:

Basic domain-employee relationship


class Domain < Familia::Horreum
  field :name
  field :created_at

  participates_in Employee, :domains, score: :created_at
end

# Usage:
domain.add_to_customer_domains(customer)  # Add domain to customer's collection
customer.domains.first                    # Most recent domain
domain.in_customer_domains?(customer)     # true
domain.current_participations             # All collections domain belongs to

Multi-collection participation with different types


class Employee < Familia::Horreum
  field :hire_date
  field :skill_level

  # Sorted by hire date in department
  participates_in Department, :members, score: :hire_date

  # Simple set membership in teams
  participates_in Team, :contributors, score: :skill_level, type: :set

  # Complex scoring for project assignments
  participates_in Project, :assignees, score: -> {
    base_score = skill_level * 100
    seniority = (Time.now - hire_date) / 1.year
    base_score + seniority * 10
  }
end

# Employee can belong to department, multiple teams, and projects
employee.add_to_department_members(engineering_dept)
employee.add_to_team_contributors(frontend_team)
employee.add_to_project_assignees(mobile_app_project)

Parameters:

  • target (Class, Symbol, String)

    The class that owns the collection. Can be:

    • +Class+ object (e.g., +Employee+)
    • +Symbol+ referencing class name (e.g., +:employee+, +:Employee+)
    • +String+ class name (e.g., +"Employee"+)
  • collection_name (Symbol)

    Name of the collection on the target class (e.g., +:domains+, +:members+)

  • score (Symbol, Proc, Numeric, nil) (defaults to: nil)

    Scoring strategy for sorted collections:

  • type (Symbol) (defaults to: :sorted_set)

    Valkey/Redis collection type:

    • +:sorted_set+: Ordered by score, allows duplicates with different scores (default)
    • +:set+: Unordered unique membership
    • +:list+: Ordered sequence, allows duplicates
  • generate_participant_methods (Boolean) (defaults to: true)

    Whether to generate reverse collection methods on participant class. If true, methods are generated using the name of the target class. (default: +true+)

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

    Custom name for reverse collection methods (e.g., +as: :contracting_orgs+). When provided, overrides the default method name derived from the target class.

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

    Optional join model class for storing additional attributes on the relationship. The through model:

See Also:



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
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
# File 'lib/familia/features/relationships/participation.rb', line 298

def participates_in(target, collection_name, score: nil, type: :sorted_set, generate_participant_methods: true, as: nil, through: nil, method_prefix: nil)

  # Normalize the target class parameter
  target_class = Familia.resolve_class(target)

  # Raise helpful error if target class can't be resolved
  if target_class.nil?
    raise ArgumentError, <<~ERROR
      Cannot resolve target class: #{target.inspect}

      The target class '#{target}' could not be found in Familia.members.
      This usually means:
      1. The target class hasn't been loaded/required yet (load order issue)
      2. The target class name is misspelled
      3. The target class doesn't inherit from Familia::Horreum

      Current registered classes: #{Familia.members.filter_map(&:name).sort.join(', ')}

      Solution: Ensure #{target} is defined and loaded before #{self.name}
    ERROR
  end

  # Validate through class if provided
  if through
    through_class = Familia.resolve_class(through)
    raise ArgumentError, "Cannot resolve through class: #{through.inspect}" unless through_class

    unless through_class.respond_to?(:features_enabled) &&
           through_class.features_enabled.include?(:object_identifier)
      raise ArgumentError, "Through model #{through_class} must use `feature :object_identifier`"
    end
  end

  # Store metadata for this participation relationship
  participation_relationships << ParticipationRelationship.new(
    _original_target: target,      # Original value as passed (Symbol/String/Class)
    target_class: target_class,    # Resolved Class object
    collection_name: collection_name,
    score: score,
    type: type,
    generate_participant_methods: generate_participant_methods,
    through: through,
    method_prefix: method_prefix,
  )

  # STEP 0: Add participations tracking field to PARTICIPANT class (Domain)
  # This creates the proper key: "domain:123:participations"
  set :participations unless method_defined?(:participations)

  # STEP 1: Add collection management methods to TARGET class (Employee)
  # Employee gets: domains, add_domain, remove_domain, etc.
  TargetMethods::Builder.build(target_class, collection_name, type, through)

  # STEP 2: Add participation methods to PARTICIPANT class (Domain) - only if
  # generate_participant_methods. e.g. in_employee_domains?, add_to_employee_domains, etc.
  if generate_participant_methods
    # `as` parameter allows custom naming for reverse collections
    # If not provided, we'll let the builder use the pluralized target class name
    ParticipantMethods::Builder.build(self, target_class, collection_name, type, as, through, method_prefix)
  end
end

#participation_relationshipsArray<ParticipationRelationship>

Get all participation relationships defined for this class.

Returns an array of ParticipationRelationship objects containing metadata about each participation relationship, including target class, collection name, scoring strategy, and configuration options.

Returns:

Since Version:

  • 1.0.0



368
369
370
# File 'lib/familia/features/relationships/participation.rb', line 368

def participation_relationships
  @participation_relationships ||= []
end