Module: Familia::Features::Relationships::Participation::ModelInstanceMethods

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

Overview

Instance methods available on objects that participate in collections.

These methods provide the core functionality for participation management, including score calculation, membership tracking, and participation queries.

Instance Method Summary collapse

Instance Method Details

#calculate_participation_score(target_class, collection_name) ⇒ Float

Calculate the appropriate score for a participation relationship based on configured scoring strategy.

This method serves as the single source of truth for participation scoring across the entire relationship lifecycle. It supports multiple scoring strategies and provides robust fallback behavior for edge cases and error conditions.

The calculated score determines the object's position within sorted collections and can be dynamically recalculated as object state changes, enabling responsive collection ordering based on real-time business logic.

=== Scoring Strategies

[Symbol] Field name or method name - calls +send(symbol)+ on the instance

  • +:priority_level+ - Uses value of priority_level field
  • +:created_at+ - Uses timestamp for chronological ordering
  • +:calculate_importance+ - Calls custom method for complex logic

[Proc] Dynamic calculation executed in instance context using +instance_exec+

  • +-> { skill_level * experience_years }+ - Combines multiple fields
  • +-> { active? ? 100 : 0 }+ - Conditional scoring based on state
  • +-> { Rails.cache.fetch("score:#id") { expensive_calculation } }+ - Cached computations

[Numeric] Static score applied uniformly to all instances

  • +50.0+ - All instances get same floating-point score
  • +100+ - All instances get same integer score (converted to float)

[nil] Uses +current_score+ method as fallback if available

=== Performance Considerations

  • Score calculations are performed on-demand during collection operations
  • Proc-based calculations should be efficient as they may be called frequently
  • Consider caching expensive calculations within the Proc itself
  • Static numeric scores have no performance overhead

=== Thread Safety

Score calculations should be idempotent and thread-safe since they may be called concurrently during collection updates. Avoid modifying instance state within scoring Procs.

Examples:

Field-based scoring

class Task < Familia::Horreum
  field :priority  # 1=low, 5=high
  participates_in Project, :tasks, score: :priority
end

task.priority = 5
score = task.calculate_participation_score(Project, :tasks)  # => 5.0

Complex business logic with multiple factors

class Employee < Familia::Horreum
  field :hire_date
  field :performance_rating
  field :salary

  participates_in Department, :members, score: -> {
    tenure_months = (Time.now - hire_date) / 1.month
    base_score = tenure_months * 10
    performance_bonus = performance_rating * 100
    salary_factor = salary / 1000.0

    (base_score + performance_bonus + salary_factor).round(2)
  }
end

# Score reflects seniority, performance, and compensation
employee.performance_rating = 4.5
employee.salary = 85000
score = employee.calculate_participation_score(Department, :members)  # => 1375.0

Parameters:

  • target_class (Class, Symbol, String)

    The target class containing the collection

    • For instance-level participation: Class object (e.g., +Project+, +Team+)
    • For class-level participation: The string +'class'+ (from +class_participates_in+)
  • collection_name (Symbol)

    The collection name within the target class

Returns:

  • (Float)

    Calculated score for sorted set positioning, falls back to current_score

See Also:

Since Version:

  • 1.0.0



434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
# File 'lib/familia/features/relationships/participation.rb', line 434

def calculate_participation_score(target_class, collection_name)
  # Find the participation configuration using the new matches? method
  participation_config = self.class.participation_relationships.find do |details|
    details.matches?(target_class, collection_name)
  end

  return current_score unless participation_config

  score_calculator = participation_config.score

  # Get the raw result based on calculator type
  result = case score_calculator
           when Symbol
             # Field name or method name
             respond_to?(score_calculator) ? send(score_calculator) : nil
           when Proc
             # Execute proc in context of this instance
             instance_exec(&score_calculator)
           when Numeric
             # Static numeric value
             return score_calculator.to_f
           else
             # Unrecognized type
             return current_score
  end

  # Convert result to appropriate score with unified logic
  convert_to_score(result)
end

#current_participationsObject



629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
# File 'lib/familia/features/relationships/participation.rb', line 629

def current_participations
  return [] unless self.class.respond_to?(:participation_relationships)

  # Use the reverse index as the single source of truth
  collection_keys = participations.members
  return [] if collection_keys.empty?

  memberships = []

  # Check membership in each tracked collection using DataType methods
  collection_keys.each do |collection_key|
    # Parse the collection key to extract target info
    # Expected format: "targetclass:targetid:collectionname"
    target_class_config, target_id, collection_name_from_key = collection_key.split(Familia.delim, 3)
    next unless target_class_config && target_id && collection_name_from_key

    # Find the matching participation configuration
    # Note: target_class_config from key is snake_case
    config = self.class.participation_relationships.find do |cfg|
      cfg.target_class.config_name == target_class_config &&
        cfg.collection_name.to_s == collection_name_from_key
    end

    next unless config

    # Find the target instance and check membership using Horreum DataTypes
    # config.target_class is already a resolved Class object
    begin
      target_instance = config.target_class.find_by_id(target_id)
      next unless target_instance

      # Use Horreum's DataType accessor to get the collection
      collection = target_instance.send(config.collection_name)

      # Check membership using DataType methods and build ParticipationMembership
      score = nil
      decoded_score = nil
      position = nil

      case config.type
      when :sorted_set
        score = collection.score(identifier)
        next unless score

        decoded_score = decode_score(score) if respond_to?(:decode_score)
      when :set
        is_member = collection.member?(identifier)
        next unless is_member
      when :list
        position = collection.to_a.index(identifier)
        next unless position
      end

      # Create ParticipationMembership instance
      # Use target_class_base to get clean class name without namespace
      membership = ParticipationMembership.new(
        target_class: config.target_class_base,
        target_id: target_id,
        collection_name: config.collection_name,
        type: config.type,
        score: score,
        decoded_score: decoded_score,
        position: position
      )

      memberships << membership
    rescue StandardError => e
      Familia.debug "[#{collection_key}] Error checking membership: #{e.message}"
      next
    end
  end

  memberships
end

#participating_ids_for_target(target_class, collection_names = nil) ⇒ Array<Hash>, Array<String>

Get comprehensive information about all collections this object participates in.

This method leverages the reverse index to efficiently retrieve membership details across all collections without requiring expensive scans. For each membership, it provides collection metadata, membership details, and type-specific information like scores or positions.

The method handles missing target objects gracefully and validates membership using the actual DataType collections to ensure accuracy.

=== Return Format

Returns an array of hashes, each containing:

  • +:target_class+ - Name of the class owning the collection
  • +:target_id+ - Identifier of the specific target instance
  • +:collection_name+ - Name of the collection within the target
  • +:type+ - Collection type (:sorted_set, :set, :list)

Additional fields based on collection type:

  • +:score+ - Current score (sorted_set only)
  • +:decoded_score+ - Human-readable score if decode_score method exists
  • +:position+ - Zero-based position in the list (list only)

Get all IDs where this instance participates for a specific target class

This is a shallow check - it extracts IDs from the participation index without verifying that the target Redis keys actually exist. Use this for fast ID enumeration; use *_instances methods if you need existence verification.

Optimized to iterate through keys once and use Set for efficient uniqueness, reducing string operations and object allocations.

Examples:

Employee participating in multiple collections

class Employee < Familia::Horreum
  field :name
  participates_in Department, :members, score: :hire_date
  participates_in Team, :contributors, score: :skill_level, type: :set
  participates_in Project, :assignees, score: :priority, type: :list
end

employee.add_to_department_members(engineering)
employee.add_to_team_contributors(frontend_team)
employee.add_to_project_assignees(mobile_project)

# Query all memberships
memberships = employee.current_participations
# => [
#   {
#     target_class: "Department",
#     target_id: "engineering",
#     collection_name: :members,
#     type: :sorted_set,
#     score: 1640995200.0,
#     decoded_score: "2022-01-01 00:00:00 UTC"
#   },
#   {
#     target_class: "Team",
#     target_id: "frontend",
#     collection_name: :contributors,
#     type: :set
#   },
#   {
#     target_class: "Project",
#     target_id: "mobile",
#     collection_name: :assignees,
#     type: :list,
#     position: 2
#   }
# ]

Parameters:

  • target_class (Class)

    The target class to filter by

  • collection_names (Array<String>, nil) (defaults to: nil)

    Optional collection name filter

Returns:

  • (Array<Hash>)

    Array of membership details with collection metadata

  • (Array<String>)

    Array of unique target instance IDs

See Also:

Since Version:

  • 1.0.0



578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
# File 'lib/familia/features/relationships/participation.rb', line 578

def participating_ids_for_target(target_class, collection_names = nil)

  # Use config_name to get the proper snake_case format (e.g., "project_team")
  target_prefix = "#{target_class.config_name}#{Familia.delim}"
  ids = Set.new

  participations.members.each do |key|
    next unless key.start_with?(target_prefix)

    parts = key.split(Familia.delim, 3)  # Split into ["targetclass", "id", "collection"]
    id = parts[1]

    # If filtering by collection names, check before adding
    if collection_names && !collection_names.empty?
      collection = parts[2]
      ids << id if collection_names.include?(collection)
    else
      ids << id
    end
  end

  ids.to_a
end

#participating_in_target?(target_class, collection_names = nil) ⇒ Boolean

Check if this instance participates in any target of a specific class

This is a shallow check - it only verifies that participation entries exist in the participation index. It does NOT verify that the target Redis keys actually exist. Use this for fast membership checks.

Optimized to stop scanning as soon as a match is found.

Parameters:

  • target_class (Class)

    The target class to check

  • collection_names (Array<String>, nil) (defaults to: nil)

    Optional collection name filter

Returns:

  • (Boolean)

    true if any matching participation exists



613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
# File 'lib/familia/features/relationships/participation.rb', line 613

def participating_in_target?(target_class, collection_names = nil)
  target_prefix = "#{target_class.config_name}#{Familia.delim}"

  participations.members.any? do |key|
    next false unless key.start_with?(target_prefix)

    # If filtering by specific collections, check the collection name
    if collection_names && !collection_names.empty?
      collection = key.split(Familia.delim, 3)[2]
      collection_names.include?(collection)
    else
      true
    end
  end
end

#track_participation_in(collection_key) ⇒ Object

Add participation tracking to the reverse index.

This method maintains the reverse index that tracks which collections this object participates in. The reverse index enables efficient lookup of all memberships via +current_participations+ without requiring expensive scans.

The collection key follows the pattern: +"targetclass:targetid:collectionname"+

Examples:

domain.track_participation_in("customer:123:domains")

Parameters:

  • collection_key (String)

    Unique identifier for the collection (format: "class:id:collection")

See Also:

Since Version:

  • 1.0.0



478
479
480
481
# File 'lib/familia/features/relationships/participation.rb', line 478

def track_participation_in(collection_key)
  # Use Horreum's DataType field instead of manual key construction
  participations.add(collection_key)
end

#untrack_participation_in(collection_key) ⇒ Object

Remove participation tracking from the reverse index.

This method removes the collection key from the reverse index when the object is removed from a collection. This keeps the reverse index accurate and prevents stale references from appearing in +current_participations+ results.

Examples:

domain.untrack_participation_in("customer:123:domains")

Parameters:

  • collection_key (String)

    Collection identifier to remove from tracking

See Also:

Since Version:

  • 1.0.0



495
496
497
498
# File 'lib/familia/features/relationships/participation.rb', line 495

def untrack_participation_in(collection_key)
  # Use Horreum's DataType field instead of manual key construction
  participations.remove(collection_key)
end