Module: Familia::Features::Relationships::Indexing::ModelInstanceMethods
- Defined in:
- lib/familia/features/relationships/indexing.rb
Overview
Instance methods for indexed objects
Instance Method Summary collapse
-
#_ensure_persisted_before_index_write!(index_name, scope_instance = nil) ⇒ Object
Fail fast when this object has never been persisted.
-
#auto_update_instance_indexes(tracked_entries) ⇒ void
Refresh instance-scoped indexes during save, for every scope instance previously registered via add_to_*.
-
#current_indexings ⇒ Array<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.
-
#guard_tracked_index_scopes!(tracked_entries) ⇒ void
Validate instance-scoped unique constraints before save writes anything.
-
#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).
-
#read_instance_index_scopes ⇒ Hash<String, String>
Read instance-scoped index tracker entries.
-
#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.
-
#remove_tracked_index_entries!(tracked_entries) ⇒ Object
Remove instance-scoped index entries using pre-read tracker data.
-
#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.
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.
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:
- 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.
- The index mutation commits atomically with the object hash.
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_indexings ⇒ Array<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
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.
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)
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_scopes ⇒ Hash<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.
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
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).
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 |