Module: Familia::Features::Relationships::Indexing::ModelInstanceMethods

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

Overview

Instance methods for indexed objects

Instance Method Summary collapse

Instance Method Details

#_ensure_persisted_before_index_write!(index_name, scope_instance = nil) ⇒ Object

Fail fast when this object has never been persisted. Called by the generated add_to_/update_in_ methods (both instance-scoped and class-level variants) before any write. An index entry stores this object's identifier, so indexing an unsaved object plants a dangling pointer in the index — and if the process never saves, no tracker entry or destroy! pass can find it to clean up.

Skipped inside a transaction/pipeline, where the EXISTS probe would queue into the caller's MULTI and return a Future instead of a boolean (same conservatism as DataType#warn_if_dirty!). This is also what keeps the save path working: auto_update_class_indexes and the rebuild strategies call these methods inside a MULTI, where the object hash write is queued alongside the index write.

Parameters:

  • index_name (Symbol)

    the index being written

  • scope_instance (Object, nil) (defaults to: nil)

    scope for instance-scoped indexes; nil for class-level indexes

Raises:



485
486
487
488
489
490
491
492
493
494
495
496
497
498
# File 'lib/familia/features/relationships/indexing.rb', line 485

def _ensure_persisted_before_index_write!(index_name, scope_instance = nil)
  return if Fiber[:familia_transaction]
  return if exists?

  location = if scope_instance
    "#{index_name} on #{scope_instance.class.name}"
  else
    "class-level #{index_name}"
  end
  raise Familia::PersistenceError,
        "Cannot index unsaved #{self.class.name} in #{location}: " \
        'the index entry would point to a record that does not ' \
        'exist in the database yet. Call #save first.'
end

#auto_update_instance_indexes(tracked_entries) ⇒ void

This method returns an undefined value.

Refresh instance-scoped indexes during save, for every scope instance previously registered via add_to_. The initial add_to_ stays manual (save has no scope context to invent), but once a membership is tracked, saving keeps it current.

Called from inside save's MULTI/EXEC (via persist_to_storage), for two reasons that both pin it there:

  1. Dirty tracking is still live -- clear_dirty! runs after the transaction -- so the previous value of a changed indexed field is available to retract. A post-commit hook would see empty changed_fields and could never retract stale entries.
  2. The index mutation commits atomically with the object hash.

Parameters:

  • tracked_entries (Hash<String, String>)

    tracker entries pre-read OUTSIDE the transaction by read_instance_index_scopes. Passed in rather than read here: HGETALL inside MULTI returns futures, not values.



221
222
223
224
225
226
227
228
229
230
231
# File 'lib/familia/features/relationships/indexing.rb', line 221

def auto_update_instance_indexes(tracked_entries)
  return if tracked_entries.nil? || tracked_entries.empty?

  # { field => [old_value, new_value] }; still populated inside the
  # save transaction (see above).
  changes = changed_fields

  _each_tracked_membership(tracked_entries) do |config, scope_config, scope_instance|
    _apply_instance_index_change(config, scope_config, scope_instance, changes)
  end
end

#current_indexingsArray<Hash>

Get all indexes this object appears in Note: For instance-scoped indexes, this only shows class-level indexes since instance-scoped indexes require a specific scope instance

Returns:

  • (Array<Hash>)

    Array of index information



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
731
732
733
734
735
736
737
738
739
740
741
742
743
744
# File 'lib/familia/features/relationships/indexing.rb', line 682

def current_indexings
  return [] unless self.class.respond_to?(:indexing_relationships)

  memberships = []

  self.class.indexing_relationships.each do |config|
    field = config.field
    index_name = config.index_name
    cardinality = config.cardinality
    field_value = send(field)

    next unless field_value

    # Class-level indexes have within: nil (unique_index) or within: :class (multi_index)
    # Instance-scoped indexes have within: SomeClass (a specific class)
    if config.within.nil? || config.within == :class
      if cardinality == :unique
        # Class-level unique index - check hash key using DataType
        index_hash = self.class.send(index_name)
        next unless index_hash.key?(field_value.to_s)

        memberships << {
          scope_class: 'class',
          index_name: index_name,
          field: field,
          field_value: field_value,
          index_key: index_hash.dbkey,
          cardinality: cardinality,
          type: 'unique_index',
        }
      else
        # Class-level multi index - check set membership using factory method
        index_set = self.class.send("#{index_name}_for", field_value)
        next unless index_set.member?(identifier)

        memberships << {
          scope_class: 'class',
          index_name: index_name,
          field: field,
          field_value: field_value,
          index_key: index_set.dbkey,
          cardinality: cardinality,
          type: 'multi_index',
        }
      end
    else
      # Instance-scoped index (unique_index or multi_index with within:) - cannot check without scope instance
      # This would require scanning all possible scope instances
      memberships << {
        scope_class: config.scope_class_config_name,
        index_name: index_name,
        field: field,
        field_value: field_value,
        index_key: 'scope_dependent',
        cardinality: cardinality,
        type: cardinality == :unique ? 'unique_index' : 'multi_index',
        note: 'Requires scope instance for verification',
      }
    end
  end

  memberships
end

#guard_tracked_index_scopes!(tracked_entries) ⇒ void

This method returns an undefined value.

Validate instance-scoped unique constraints before save writes anything. The instance-scoped counterpart to Horreum::Persistence#guard_unique_indexes!, which only covers class-level relationships.

MUST be called OUTSIDE the save transaction: the guard reads the scope's index hash, and reads inside MULTI return futures.

Without this, save's auto-refresh reached update_in_* -- which has no uniqueness guard, unlike add_to_* -- so changing an indexed field to a value another record already held silently evicted that record's entry. The evicted record's tracker still claimed the slot, so its later destroy! unindexed the live winner. The class-level path always raised here; this closes the asymmetry.

Only changed fields are guarded. An unchanged value would be validated against its own existing entry -- harmless, since the guard compares existing_id to identifier, but it would also make every save re-read every tracked index for no benefit, and would start failing saves if an external actor took the slot.

Parameters:

  • tracked_entries (Hash<String, String>)

    pre-read tracker

Raises:



292
293
294
295
296
297
298
299
300
301
302
303
304
305
# File 'lib/familia/features/relationships/indexing.rb', line 292

def guard_tracked_index_scopes!(tracked_entries)
  return if tracked_entries.nil? || tracked_entries.empty?

  changes = changed_fields

  _each_tracked_membership(tracked_entries) do |config, scope_config, scope_instance|
    # Multi indexes have no uniqueness to enforce.
    next unless config.cardinality == :unique
    next unless changes.key?(config.field)

    guard_method = :"guard_unique_#{scope_config}_#{config.index_name}!"
    send(guard_method, scope_instance) if respond_to?(guard_method)
  end
end

#indexed_in?(index_name) ⇒ Boolean

Check if this object is indexed in a specific scope For class-level indexes, checks the hash key (unique) or set membership (multi) For instance-scoped indexes, returns false (requires scope instance)

Returns:

  • (Boolean)


749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
# File 'lib/familia/features/relationships/indexing.rb', line 749

def indexed_in?(index_name)
  return false unless self.class.respond_to?(:indexing_relationships)

  config = self.class.indexing_relationships.find { |rel| rel.index_name == index_name }
  return false unless config

  field = config.field
  field_value = send(field)
  return false unless field_value

  # Class-level indexes have within: nil (unique_index) or within: :class (multi_index)
  # Instance-scoped indexes have within: SomeClass (a specific class)
  if config.within.nil? || config.within == :class
    if config.cardinality == :unique
      # Class-level unique index - check hash key using DataType
      index_hash = self.class.send(index_name)
      index_hash.key?(field_value.to_s)
    else
      # Class-level multi index - check set membership using factory method
      index_set = self.class.send("#{index_name}_for", field_value)
      index_set.member?(identifier)
    end
  else
    # Instance-scoped index (with within:) - cannot verify without scope instance
    false
  end
end

#read_instance_index_scopesHash<String, String>

Read instance-scoped index tracker entries. Must be called BEFORE entering a MULTI/EXEC transaction since HGETALL inside MULTI returns futures, not values.

Tracker cardinality mirrors index cardinality, so the tracker can describe the true state rather than approximate it:

  • unique index (1:1): key is the "\t\t" triple. The object occupies exactly one bucket per scope, and update_in_* retracts the old one, so HSET-overwrite is exact.
  • multi index (1:many): key appends the value -- "...\t\t". Refresh is add-only, so after a value change the object really is in several buckets at once; a triple-keyed entry could only name one of them and destroy! would orphan the rest.

Every value is the field value written into that index -- the bucket to clean up. It is stored rather than re-read from the object at cleanup time so removal still finds the right bucket when the indexed field has since changed, or when the object was loaded identifier-only and has no field values in memory.

Returns:

  • (Hash<String, String>)

    tracker entries, empty if none



365
366
367
368
369
# File 'lib/familia/features/relationships/indexing.rb', line 365

def read_instance_index_scopes
  return {} unless _has_instance_scoped_indexes?

  _index_scope_tracker.hgetall
end

#remove_from_all_indexes(scope_context = nil) ⇒ Object

Remove from all indexes for a given scope context For class-level indexes (unique_index without within:), scope_context should be nil For instance-scoped indexes (with within:), scope_context should be the scope instance



185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
# File 'lib/familia/features/relationships/indexing.rb', line 185

def remove_from_all_indexes(scope_context = nil)
  return unless self.class.respond_to?(:indexing_relationships)

  self.class.indexing_relationships.each do |config|
    index_name = config.index_name

    if config.class_level?
      send("remove_from_class_#{index_name}")
    else
      next unless scope_context

      scope_class_config = Familia.resolve_class(config.scope_class).config_name
      send("remove_from_#{scope_class_config}_#{index_name}", scope_context)
    end
  end
end

#remove_tracked_index_entries!(tracked_entries) ⇒ Object

Note:

Every key touched here (the scope's index, this object's tracker) must live in the same logical database as the caller's transaction -- the standing cross-database constraint on MULTI/EXEC (see AGENTS.md on atomic_write). A scope class configured with a different logical_database cannot participate. Note this now binds save as well as destroy!: before the instance-scoped refresh, save never touched scope-owned keys, so a cross-database scope was only a destroy!-time concern.

Remove instance-scoped index entries using pre-read tracker data. Safe to call inside a MULTI/EXEC transaction since all operations are writes (HDEL, SREM, DEL).

Parameters:

  • tracked_entries (Hash<String, String>)

    entries from read_instance_index_scopes ({ entry key => indexed field value })



386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
# File 'lib/familia/features/relationships/indexing.rb', line 386

def remove_tracked_index_entries!(tracked_entries)
  return if tracked_entries.nil? || tracked_entries.empty?

  tracked_entries.each do |entry, field_value|
    scope_config, idx_name, scope_id = _parse_index_scope_entry(entry)
    next unless scope_config && idx_name && scope_id

    config = _find_instance_index_config(scope_config, idx_name)
    next unless config

    scope_instance = _build_scope_stub(config.scope_class, scope_id)
    remove_method = :"remove_from_#{scope_config}_#{idx_name}"
    # Pass the RECORDED field value: send(field) may have changed
    # (or be nil on an identifier-only instance), which would target
    # the wrong bucket or skip removal entirely.
    send(remove_method, scope_instance, field_value.to_s) if respond_to?(remove_method)
  end

  _index_scope_tracker.delete!
end

#update_all_indexes(old_values = {}, scope_context = nil) ⇒ Object

Update all indexes for a given scope context For class-level indexes (unique_index without within:), scope_context should be nil For instance-scoped indexes (with within:), scope_context should be the scope instance



163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
# File 'lib/familia/features/relationships/indexing.rb', line 163

def update_all_indexes(old_values = {}, scope_context = nil)
  return unless self.class.respond_to?(:indexing_relationships)

  self.class.indexing_relationships.each do |config|
    field = config.field
    index_name = config.index_name
    old_field_value = old_values[field]

    if config.class_level?
      send("update_in_class_#{index_name}", old_field_value)
    else
      next unless scope_context

      scope_class_config = Familia.resolve_class(config.scope_class).config_name
      send("update_in_#{scope_class_config}_#{index_name}", scope_context, old_field_value)
    end
  end
end