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



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

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



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
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
# File 'lib/familia/features/relationships/participation.rb', line 653

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 uses prefix (may differ from config_name)
    config = self.class.participation_relationships.find do |cfg|
      cfg.target_class.prefix.to_s == 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
        # Pass self (Familia object) not identifier (string) for correct serialization
        # See GitHub issue #212: serialize_value handles objects vs strings differently
        score = collection.score(self)
        next unless score

        decoded_score = decode_score(score) if respond_to?(:decode_score)
      when :set
        # Pass self (Familia object) not identifier (string) for correct serialization
        is_member = collection.member?(self)
        next unless is_member
      when :list
        # List uses to_a which returns deserialized values, so identifier is correct here
        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



602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
# File 'lib/familia/features/relationships/participation.rb', line 602

def participating_ids_for_target(target_class, collection_names = nil)
  # Use centralized key_prefix method for consistent key generation
  target_prefix = target_class.key_prefix
  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



636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
# File 'lib/familia/features/relationships/participation.rb', line 636

def participating_in_target?(target_class, collection_names = nil)
  # Use centralized key_prefix method for consistent key generation
  target_prefix = target_class.key_prefix

  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



502
503
504
505
# File 'lib/familia/features/relationships/participation.rb', line 502

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



519
520
521
522
# File 'lib/familia/features/relationships/participation.rb', line 519

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