Module: Familia::Features::Relationships

Defined in:
lib/familia/features/relationships.rb,
lib/familia/features/relationships/indexing.rb,
lib/familia/features/relationships/participation.rb,
lib/familia/features/relationships/score_encoding.rb,
lib/familia/features/relationships/collection_operations.rb,
lib/familia/features/relationships/indexing_relationship.rb,
lib/familia/features/relationships/participation_membership.rb,
lib/familia/features/relationships/participation_relationship.rb,
lib/familia/features/relationships/indexing/rebuild_strategies.rb,
lib/familia/features/relationships/participation/target_methods.rb,
lib/familia/features/relationships/indexing/multi_index_generators.rb,
lib/familia/features/relationships/indexing/unique_index_generators.rb,
lib/familia/features/relationships/participation/participant_methods.rb,
lib/familia/features/relationships/participation/through_model_operations.rb

Overview

Unified Relationships feature for Familia v2

This feature merges the functionality of relatable_objects and relationships into a single, Valkey/Redis-native implementation that embraces the "where does this appear?" philosophy rather than "who owns this?".

Examples:

Basic usage

class Domain < Familia::Horreum

  identifier_field :domain_id

  field :domain_id
  field :display_name
  field :created_at
  field :permission_bits

  feature :relationships

  # Multi-presence participation with score encoding
  participates_in Customer, :domains,
                  score: -> { permission_encode(created_at, permission_bits) }
  participates_in Team, :domains, score: :added_at
  participates_in Organization, :all_domains, score: :created_at

  # O(1) lookups with Valkey/Redis hashes
  indexed_by :display_name, :domain_index, target: Customer

  # Participation with bidirectional control (no method collisions)
  participates_in Customer, :domains
  participates_in Team, :domains, generate_participant_methods: false
  participates_in Organization, :domains, type: :set
end

Generated methods (collision-free)

# Participation methods
Customer.domains                    # => Familia::SortedSet
Customer.add_domain(domain, score)  # Add to customer's domains
domain.in_customer_domains?(customer) # Check membership

# Indexing methods
Customer.find_by_display_name(name) # O(1) lookup

# Bidirectional methods (collision-free naming)
domain.add_to_customer_domains(customer)  # Specific collection
domain.add_to_team_domains(team)          # Different collection
domain.in_customer_domains?(customer)     # Check specific membership

Score encoding for permissions

# Encode permission in score
score = domain.permission_encode(Familia.now, :write)
# => 1704067200.004 (timestamp + permission bits)

# Decode permission from score
decoded = domain.permission_decode(score)
# => { timestamp: 1704067200, permissions: 4, permission_list: [:write] }

# Query with permission filtering
Customer.domains_with_permission(:read)

Multi-collection operations

# Atomic updates across multiple collections
domain.update_multiple_presence([
  { key: "customer:123:domains", score: current_score },
  { key: "team:456:domains", score: permission_encode(Familia.now, :read) }
], :add, domain.identifier)

# UnsortedSet operations on collections
accessible = Domain.union_collections([
  { owner: customer, collection: :domains },
  { owner: team, collection: :domains }
], min_permission: :read)

Defined Under Namespace

Modules: CollectionOperations, Indexing, ModelClassMethods, ModelInstanceMethods, ParticipantMethods, Participation, ScoreEncoding, TargetMethods Classes: CascadeError, InvalidIdentifierError, InvalidScoreError, RelationshipError

Constant Summary collapse

IndexingRelationship =

IndexingRelationship

Stores metadata about indexing relationships defined at class level. Used to configure code generation and runtime behavior for unique_index and multi_index declarations.

Similar to ParticipationRelationship but for attribute-based lookups rather than collection membership.

Terminology:

  • scope_class: The class that provides the uniqueness boundary for instance-scoped indexes. For example, in unique_index :badge_number, :badge_index, within: Company, the Company is the scope class.
  • within: Preserves the original DSL parameter to explicitly distinguish class-level indexes (within: nil) from instance-scoped indexes (within: SomeClass). This avoids brittle class comparisons and prevents issues with inheritance scenarios.
Data.define(
  :field,              # Symbol - field being indexed (e.g., :email, :department)
  :index_name,         # Symbol - name of the index (e.g., :email_index, :dept_index)
  :scope_class,        # Class/Symbol - scope class for instance-scoped indexes (within:)
  :within,             # Class/Symbol/nil - within: parameter (nil for class-level, Class for instance-scoped)
  :cardinality,        # Symbol - :unique (1:1) or :multi (1:many)
  :query,              # Boolean - whether to generate query  methods
) do
  #
  # Get the normalized config name for the scope class
  #
  # @return [String] The config name (e.g., "user", "company", "test_company")
  #
  def scope_class_config_name
    scope_class.config_name
  end
end
ParticipationMembership =
Note:

This represents what currently exists in Redis, not just configuration. See ParticipationRelationship for static configuration metadata.

ParticipationMembership

Represents runtime snapshot of a participant's membership in a target collection. Returned by current_participations to provide a type-safe, structured view of actual participation state.

Examples:

membership = user.current_participations.first
membership.target_class  # => "Team"
membership.target_id     # => "team123"
membership.collection_name  # => :members
membership.type          # => :sorted_set
membership.score         # => 1762554020.05
Data.define(
  :target_class,      # String - class name (e.g., "Customer")
  :target_id,         # String - target instance identifier
  :collection_name,   # Symbol - collection name (e.g., :domains)
  :type,              # Symbol - collection type (:sorted_set, :set, :list)
  :score,             # Float - optional, for sorted_set only
  :decoded_score,     # Hash - optional, decoded score data
  :position           # Integer - optional, for list only
) do
  # Check if this membership is a sorted set
  # @return [Boolean]
  def sorted_set?
    type == :sorted_set
  end

  # Check if this membership is a set
  # @return [Boolean]
  def set?
    type == :set
  end

  # Check if this membership is a list
  # @return [Boolean]
  def list?
    type == :list
  end

  # Get the target instance (requires loading from database)
  # @return [Familia::Horreum, nil] the loaded target instance
  def target_instance
    return nil unless target_class

    # Resolve class from string name
    # Only rescue NameError (class doesn't exist), not all exceptions
    klass = Object.const_get(target_class)
    klass.find_by_id(target_id)
  rescue NameError
    # Target class doesn't exist or isn't loaded
    nil
  end
end
ParticipationRelationship =
Note:

target_class is resolved once at definition time for performance. Use _original_target for debugging/introspection to see what was passed.

ParticipationRelationship

Stores metadata about participation relationships defined at class level. Used to configure code generation and runtime behavior for participates_in and class_participates_in declarations.

Data.define(
  :_original_target,    # Original Symbol/String/Class as passed to participates_in
  :target_class,        # Resolved Class object (e.g., User class, not :User symbol)
  :collection_name,     # Symbol name of the collection (e.g., :members, :domains)
  :score,               # Proc/Symbol/nil - score calculator for sorted sets
  :type,                # Symbol - collection type (:sorted_set, :set, :list)
  :generate_participant_methods,  # Boolean - whether to generate participant methods
  :through,             # Symbol/Class/nil - through model class for join table pattern
  :method_prefix,       # Symbol/nil - custom prefix for reverse method names (e.g., :team)
) do
  # Get a unique key for this participation relationship
  # Useful for comparisons and hash keys
  #
  # @return [String] unique identifier in format "TargetClass:collection_name"
  def unique_key
    Familia.join(target_class_base, collection_name)
  end

  # Get the base class name without namespace
  # Handles anonymous class wrappers like "#<Class:0x123>::SymbolResolutionCustomer"
  #
  # @return [String] base class name (e.g., "Customer")
  def target_class_base
    target_class.name.split('::').last
  end

  # Check if this relationship matches the given target and collection
  # Handles namespace-agnostic class comparison
  #
  # @param comparison_target [Class, String, Symbol] target to compare against
  # @param comparison_collection [Symbol, String] collection name to compare
  # @return [Boolean] true if both target and collection match
  def matches?(comparison_target, comparison_collection)
    # Normalize comparison target to base class name
    comparison_target = comparison_target.name if comparison_target.is_a?(Class)
    comparison_target_base = comparison_target.to_s.split('::').last

    target_class_base == comparison_target_base &&
      collection_name == comparison_collection.to_sym
  end

  # Check if this relationship uses a through model
  #
  # @return [Boolean] true if through model is configured
  def through_model?
    !through.nil?
  end

  # Resolve the through class to an actual Class object
  #
  # @return [Class, nil] The resolved through class or nil
  def resolved_through_class
    return nil unless through

    through.is_a?(Class) ? through : Familia.resolve_class(through)
  end
end

Class Method Summary collapse

Class Method Details

.included(base) ⇒ Object

Feature initialization



88
89
90
91
92
93
94
95
96
97
98
99
100
101
# File 'lib/familia/features/relationships.rb', line 88

def self.included(base)
  Familia.debug "[#{base}] Relationships included"
  base.extend ModelClassMethods
  base.include ModelInstanceMethods

  # Include all relationship submodules and their class methods
  base.include ScoreEncoding

  base.include Participation
  base.extend Participation::ModelClassMethods

  base.include Indexing
  base.extend Indexing::ModelClassMethods
end