Module: Familia::Features::Relationships::CollectionOperations

Included in:
ParticipantMethods, ParticipantMethods::Builder, TargetMethods, TargetMethods::Builder
Defined in:
lib/familia/features/relationships/collection_operations.rb

Overview

Shared collection operations for Participation module Provides common methods for working with Horreum-managed DataType collections Used by both ParticipantMethods and TargetMethods to reduce duplication

Instance Method Summary collapse

Instance Method Details

#add_to_collection(collection, item, type:, score: nil, target_class: nil, collection_name: nil) ⇒ Object

Add an item to a collection, handling type-specific operations

Parameters:

  • collection (Familia::DataType)

    The collection to add to

  • item (Object)

    The item to add (must respond to identifier)

  • score (Float, nil) (defaults to: nil)

    Score for sorted sets

  • type (Symbol)

    Collection type



131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
# File 'lib/familia/features/relationships/collection_operations.rb', line 131

def add_to_collection(collection, item, type:, score: nil, target_class: nil, collection_name: nil)
  case type
  when :sorted_set
    # Ensure score is never nil for sorted sets
    score ||= calculate_item_score(item, target_class, collection_name)
    collection.add(item, score)
  when :list
    # Lists use push/unshift operations
    collection.add(item)
  when :set
    # Sets use simple add
    collection.add(item)
  else
    raise ArgumentError, "Unknown collection type: #{type}"
  end
end

#assert_compatible_cap!(existing, max_length, target_class, collection_name, type, dsl: nil) ⇒ Object

When a participation declaration requests a cap but the collection accessor already exists, the existing declaration wins (participation never overwrites it). Agreement is fine — repeating the cap is harmless — but a mismatch would silently produce a collection with the wrong cap (or none), so it raises instead. No-op when the participation declaration did not request a cap: a pre-declared cap is kept as-is, preserving the pre-declaration pattern.

Parameters:

  • existing (RelatedFieldDefinition, nil)

    the pre-existing declaration, or nil when the accessor exists but did not come from a related-field DSL call (handwritten method, another feature) — there is no declaration to carry a cap, so a requested cap raises with a message that says so rather than reporting a phantom max_length: nil declaration

  • dsl (String, nil) (defaults to: nil)

    the DSL method the error messages should name as the remedy; defaults to +type+ (instance-level). The class-level caller passes "class_#type" so the suggested fix matches the declaration style that actually applies.

Raises:

  • (ArgumentError)

    when the requested cap differs from the declared one, or when there is no declaration to check against



96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
# File 'lib/familia/features/relationships/collection_operations.rb', line 96

def assert_compatible_cap!(existing, max_length, target_class, collection_name, type, dsl: nil)
  return if max_length.nil?

  dsl ||= type

  if existing.nil?
    raise ArgumentError, <<~ERROR
      max_length: #{max_length} cannot be applied: #{target_class} already defines
      ##{collection_name}, but not via a `#{dsl} :#{collection_name}` declaration,
      so participation has no collection declaration to cap.

      Participation never overwrites an existing method. Either remove max_length:
      from the participation declaration, or replace the existing ##{collection_name}
      with a `#{dsl} :#{collection_name}, max_length: #{max_length}` declaration.
    ERROR
  end

  declared_max = existing.opts&.fetch(:max_length, nil)
  return if declared_max == max_length

  raise ArgumentError, <<~ERROR
    max_length: #{max_length} conflicts with the existing :#{collection_name} declaration
    on #{target_class} (max_length: #{declared_max.inspect}).

    Participation does not overwrite a collection that is already declared. Either
    remove max_length: from the participation declaration, or give the
    `#{dsl} :#{collection_name}` declaration on #{target_class} the same cap.
  ERROR
end

#bulk_add_to_collection(collection, items, type:, target_class: nil, collection_name: nil) ⇒ Object

Bulk add items to a collection using DataType methods

Parameters:

  • collection (Familia::DataType)

    The collection to add to

  • items (Array)

    Array of items to add

  • type (Symbol)

    Collection type



169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
# File 'lib/familia/features/relationships/collection_operations.rb', line 169

def bulk_add_to_collection(collection, items, type:, target_class: nil, collection_name: nil)
  return if items.empty?

  case type
  when :sorted_set
    # Add items one by one for sorted sets to ensure proper scoring
    items.each do |item|
      score = calculate_item_score(item, target_class, collection_name)
      collection.add(item, score)
    end
  when :set, :list
    # For sets and lists, add items one by one using DataType methods
    items.each do |item|
      collection.add(item)
    end
  else
    raise ArgumentError, "Unknown collection type: #{type}"
  end
end

#ensure_collection_field(target_class, collection_name, type, participant_class: nil, max_length: nil) ⇒ Object

Ensure a target class has the specified DataType field defined

When +participant_class+ is provided, the collection is declared with +record_class:+ pointing at the participant class. This is a loading-only hint: it lets +each_record+ hydrate the stored participant identifiers via +load_multi+ (issue #297) WITHOUT changing how the collection deserializes reads. +members+/+member?+/+score+ keep the generic DataType semantics, so adding participation to a collection is transparent to existing readers.

This deliberately differs from +instances+ and +unique_index+, which use +class: + reference: true+ because they also want raw-string read semantics. Participation only needs the loading capability, so it uses the narrower +record_class:+ option and avoids any read-behavior change.

Parameters:

  • target_class (Class)

    The class that should have the collection

  • collection_name (Symbol)

    Name of the collection field

  • type (Symbol)

    Collection type (:sorted_set, :set, :list)

  • participant_class (Class, nil) (defaults to: nil)

    The class whose identifiers the collection stores (the participant). When nil, the collection is declared with no record_class (each_record stays unavailable).

  • max_length (Integer, nil) (defaults to: nil)

    Cap for the collection (issue #351), validated eagerly so a bad value or unsupported type fails at class definition time rather than on first access. When the collection is pre-declared, the caps must agree — see assert_compatible_cap!.



39
40
41
42
43
44
45
46
47
48
49
50
51
52
# File 'lib/familia/features/relationships/collection_operations.rb', line 39

def ensure_collection_field(target_class, collection_name, type, participant_class: nil, max_length: nil)
  validate_max_length_option!(type, max_length)

  if target_class.method_defined?(collection_name)
    existing = target_class.related_fields[collection_name.to_s.to_sym]
    assert_compatible_cap!(existing, max_length, target_class, collection_name, type)
    return
  end

  opts = {}
  opts[:record_class] = participant_class if participant_class
  opts[:max_length] = max_length if max_length
  target_class.send(type, collection_name, **opts)
end

#member_of_collection?(collection, item) ⇒ Boolean

Check if an item is a member of a collection

Parameters:

  • collection (Familia::DataType)

    The collection to check

  • item (Object)

    The item to check (must respond to identifier)

Returns:

  • (Boolean)

    True if item is in collection



161
162
163
# File 'lib/familia/features/relationships/collection_operations.rb', line 161

def member_of_collection?(collection, item)
  collection.member?(item)
end

#remove_from_collection(collection, item, type: nil) ⇒ Object

Remove an item from a collection

Parameters:

  • collection (Familia::DataType)

    The collection to remove from

  • item (Object)

    The item to remove (must respond to identifier)

  • type (Symbol) (defaults to: nil)

    Collection type



152
153
154
155
# File 'lib/familia/features/relationships/collection_operations.rb', line 152

def remove_from_collection(collection, item, type: nil)
  # All collection types support remove/delete
  collection.remove(item)
end

#validate_max_length_option!(type, max_length) ⇒ Object

Eager definition-time validation for a max_length: passed through participation. The DataType itself validates in #initialize, but for instance-level relations that instance is created lazily on first accessor call — too late to point at the participates_in line that caused it. No-op when max_length is nil.

Raises:

  • (ArgumentError)

    on a non-positive/non-Integer value or a collection type that does not implement capping



62
63
64
65
66
67
68
69
70
71
72
73
74
# File 'lib/familia/features/relationships/collection_operations.rb', line 62

def validate_max_length_option!(type, max_length)
  return if max_length.nil?

  unless max_length.is_a?(Integer) && max_length.positive?
    raise ArgumentError, "max_length must be a positive Integer, got #{max_length.inspect}"
  end

  return if Familia::DataType.registered_type(type)&.supports_max_length?

  raise ArgumentError,
        "max_length: is not supported for type: #{type.inspect} collections " \
        '(only :sorted_set and :list implement capping)'
end