Module: Familia::Features::Relationships::Participation::ThroughModelOperations

Included in:
Familia::Features::Relationships::ParticipantMethods::Builder, TargetMethods::Builder
Defined in:
lib/familia/features/relationships/participation/through_model_operations.rb

Overview

ThroughModelOperations provides lifecycle management for through models in participation relationships.

Through models implement the join table pattern, creating an intermediate object between target and participant that can carry additional attributes (e.g., role, permissions, metadata).

Key characteristics:

  • Deterministic identifier: Built from target, participant, and through class
  • Auto-lifecycle: Created on add, destroyed on remove
  • Idempotent: Re-adding updates existing model
  • Atomic: All operations use transactions
  • Cache-friendly: Auto-updates updated_at for invalidation

Example: class Membership < Familia::Horreum feature :object_identifier field :customer_objid field :domain_objid field :role field :updated_at end

class Domain < Familia::Horreum participates_in Customer, :domains, through: :Membership end

# Through model auto-created with deterministic key customer.add_domains_instance(domain, through_attrs: { role: 'admin' }) # => #

Class Method Summary collapse

Class Method Details

.build_key(target:, participant:, through_class:) ⇒ String

Build a deterministic key for the through model

The key format ensures uniqueness and allows direct lookup: targettarget.prefix:targettarget.objid:participantparticipant.prefix:participantparticipant.objid:throughthrough.prefix

Parameters:

  • target (Object)

    The target instance (e.g., customer)

  • participant (Object)

    The participant instance (e.g., domain)

  • through_class (Class)

    The through model class

Returns:

  • (String)

    Deterministic key for the through model



53
54
55
56
57
58
# File 'lib/familia/features/relationships/participation/through_model_operations.rb', line 53

def build_key(target:, participant:, through_class:)
  # Use prefix for Redis key construction (prefix may differ from config_name if explicitly set)
  "#{target.class.prefix}:#{target.objid}:" \
  "#{participant.class.prefix}:#{participant.objid}:" \
  "#{through_class.prefix}"
end

.find_and_destroy(through_class:, target:, participant:) ⇒ void

This method returns an undefined value.

Find and destroy a through model instance

Used during remove operations to clean up the join table entry.

Parameters:

  • through_class (Class)

    The through model class

  • target (Object)

    The target instance

  • participant (Object)

    The participant instance



125
126
127
128
129
130
# File 'lib/familia/features/relationships/participation/through_model_operations.rb', line 125

def find_and_destroy(through_class:, target:, participant:)
  key = build_key(target: target, participant: participant, through_class: through_class)
  existing = through_class.load(key)
  # Use the public exists? method for a more robust check
  existing&.destroy! if existing&.exists?
end

.find_or_create(through_class:, target:, participant:, attrs: {}) ⇒ Object

Find or create a through model instance

This method is idempotent - calling it multiple times with the same target/participant pair will update the existing through model rather than creating duplicates.

The through model's updated_at is set on both create and update for cache invalidation.

Parameters:

  • through_class (Class)

    The through model class

  • target (Object)

    The target instance

  • participant (Object)

    The participant instance

  • attrs (Hash) (defaults to: {})

    Additional attributes to set on through model

Returns:

  • (Object)

    The created or updated through model instance



75
76
77
78
79
80
81
82
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
# File 'lib/familia/features/relationships/participation/through_model_operations.rb', line 75

def find_or_create(through_class:, target:, participant:, attrs: {})
  key = build_key(target: target, participant: participant, through_class: through_class)

  # Try to load existing model - load returns nil if key doesn't exist
  existing = through_class.load(key)

  # Check if we got a valid loaded object using the public API
  # This is called outside transaction boundaries (see participant_methods.rb
  # and target_methods.rb for the transaction boundary documentation)
  if existing&.exists?
    # Update existing through model with validated attributes
    safe_attrs = validated_attrs(through_class, attrs)
    safe_attrs.each { |k, v| existing.send("#{k}=", v) }
    existing.updated_at = Familia.now.to_f if existing.respond_to?(:updated_at=)
    # Save returns boolean, but we want to return the model instance
    existing.save if safe_attrs.any? || existing.respond_to?(:updated_at=)
    existing  # Return the model, not the save result
  else
    # Create new through model with our deterministic key as objid
    # Pass objid during initialization to prevent auto-generation
    inst = through_class.new(objid: key)

    # Set foreign key fields if they exist (validated via respond_to?)
    target_field = "#{target.class.config_name}_objid"
    participant_field = "#{participant.class.config_name}_objid"
    inst.send("#{target_field}=", target.objid) if inst.respond_to?("#{target_field}=")
    inst.send("#{participant_field}=", participant.objid) if inst.respond_to?("#{participant_field}=")

    # Set updated_at for cache invalidation
    inst.updated_at = Familia.now.to_f if inst.respond_to?(:updated_at=)

    # Set custom attributes (validated against field schema)
    safe_attrs = validated_attrs(through_class, attrs)
    safe_attrs.each { |k, v| inst.send("#{k}=", v) }

    # Save returns boolean, but we want to return the model instance
    inst.save
    inst  # Return the model, not the save result
  end
end

.validated_attrs(through_class, attrs) ⇒ Hash

Validate attribute keys against the through model's field schema

This prevents arbitrary method invocation by ensuring only defined fields can be set via the attrs hash.

Parameters:

  • through_class (Class)

    The through model class

  • attrs (Hash)

    Attributes to validate

Returns:

  • (Hash)

    Only attributes whose keys match defined fields



141
142
143
144
145
146
# File 'lib/familia/features/relationships/participation/through_model_operations.rb', line 141

def validated_attrs(through_class, attrs)
  return {} if attrs.nil? || attrs.empty?

  valid_fields = through_class.fields.map(&:to_sym)
  attrs.select { |k, _v| valid_fields.include?(k.to_sym) }
end