Module: Models::LtreePathBuilder
- Extended by:
- ActiveSupport::Concern
- Included in:
- ProductCategory, ProductLine, Source
- Defined in:
- app/concerns/models/ltree_path_builder.rb
Overview
Models::LtreePathBuilder - Builds ltree paths from parent_id chain
This concern makes ltree the SOURCE OF TRUTH for hierarchy:
parent_id → ltree_path_ids → ltree_path_slugs
↓ ↘
cached_ancestor_ids slug_ltree (FriendlyId slugs)
The flow is:
- User sets parent_id (and name)
- before_save builds ltree_path_ids by walking parent_id chain
- before_save builds ltree_path_slugs from names in the path
- before_save generates url from ltree_path_slugs
- before_save builds slug_ltree from FriendlyId slug chain (if column exists)
- after_commit cascades changes to ALL descendants (not just direct children)
CASCADE BEHAVIOR:
When a record's path changes (parent_id or name), ALL descendants must be updated
because their paths contain this record's path as a prefix.
Example: Floor Heating (id=1) renamed to "Radiant Floor Heating"
- 1's slug changes: floor_heating → radiant_floor_heating
- All descendants (1.2, 1.2.102, 1.2.102.103, etc.) need slug updates
- Their ltree_path_ids stay the same, but slugs and URLs change
Usage:
class ProductLine < ApplicationRecord
include Models::LtreePathBuilder
builds_ltree_path # Enable ltree path building
end
Instance Attribute Summary collapse
-
#skip_ltree_rebuild ⇒ Object
Track if path needs rebuilding (to prevent infinite loops).
Class Method Summary collapse
-
.builds_ltree_path(name_attribute: :name, cascade_to_descendants: true, lineage_column: :lineage_expanded, **_deprecated) ⇒ Object
Configure ltree path building for this model.
-
.rebuild_all_ltree_paths! ⇒ Object
Rebuild ltree paths for all records (useful for migrations/repairs).
-
.rebuild_subtree_ltree_paths!(record) ⇒ Object
Rebuild ltree paths for a subtree Uses parent_id FK directly (not the pg_ltree
childrenmethod) so this works even when ltree paths are nil/empty (initial backfill scenario). -
.syncs_items_on_ltree_change? ⇒ Boolean
Configure item sync behavior Override in model to customize (e.g., ProductCategory might sync differently).
Instance Method Summary collapse
-
#build_ltree_path_ids_value ⇒ Object
Build ltree_path_ids from parent chain.
-
#build_ltree_path_slugs_value ⇒ Object
Build ltree_path_slugs by looking up names for each ID in the path.
-
#build_slug_ltree_value ⇒ Object
Build slug_ltree from FriendlyId slug chain.
-
#compute_ltree_ancestor_ids ⇒ Object
Build ltree_path_ids by walking up the parent_id chain Returns array of IDs from root to self.
-
#derive_cached_ancestor_ids ⇒ Object
Derive cached_ancestor_ids from ltree_path_ids (backward compatibility) ltree stores root→leaf, cached_ancestor_ids stores leaf→root (excluding self).
-
#ltree_descendant_ids(use_original_path: false) ⇒ Object
Get all descendant IDs using ltree (efficient) When called during cascade (after path change), use the ORIGINAL path to find descendants since they still have the old path prefix in the DB.
Instance Attribute Details
#skip_ltree_rebuild ⇒ Object
Track if path needs rebuilding (to prevent infinite loops)
41 42 43 |
# File 'app/concerns/models/ltree_path_builder.rb', line 41 def skip_ltree_rebuild @skip_ltree_rebuild end |
Class Method Details
.builds_ltree_path(name_attribute: :name, cascade_to_descendants: true, lineage_column: :lineage_expanded, **_deprecated) ⇒ Object
Configure ltree path building for this model
Options:
name_attribute: Attribute used for slug generation (default: :name)
cascade_to_descendants: Whether to update all descendants when path changes (default: true)
lineage_column: DB column for human-readable lineage string (default: :lineage_expanded).
Source uses :full_name since it has no lineage_expanded column.
53 54 55 56 57 58 59 60 61 62 63 64 65 |
# File 'app/concerns/models/ltree_path_builder.rb', line 53 def builds_ltree_path(name_attribute: :name, cascade_to_descendants: true, lineage_column: :lineage_expanded, **_deprecated) class_attribute :ltree_name_attribute, default: name_attribute class_attribute :ltree_cascade_to_descendants, default: cascade_to_descendants class_attribute :ltree_lineage_column, default: lineage_column # Callbacks for building paths # IMPORTANT: Use before_validation so paths are built BEFORE validation runs # This allows validate_ltree_path_consistency to pass after parent_id changes before_validation :build_ltree_paths_from_parent, if: :should_rebuild_ltree_path? after_create :rebuild_ltree_after_create after_commit :cascade_ltree_to_descendants, if: :should_cascade_to_descendants? after_commit :queue_item_ltree_sync, if: :should_sync_items? end |
.rebuild_all_ltree_paths! ⇒ Object
Rebuild ltree paths for all records (useful for migrations/repairs)
75 76 77 78 79 80 |
# File 'app/concerns/models/ltree_path_builder.rb', line 75 def rebuild_all_ltree_paths! # Process in tree order (roots first, then children) roots.find_each do |root| rebuild_subtree_ltree_paths!(root) end end |
.rebuild_subtree_ltree_paths!(record) ⇒ Object
Rebuild ltree paths for a subtree
Uses parent_id FK directly (not the pg_ltree children method) so this
works even when ltree paths are nil/empty (initial backfill scenario).
85 86 87 88 89 90 91 92 93 94 |
# File 'app/concerns/models/ltree_path_builder.rb', line 85 def rebuild_subtree_ltree_paths!(record) record.skip_ltree_rebuild = false record.send(:build_ltree_paths_from_parent) record.save!(validate: false) fk = respond_to?(:ltree_foreign_key) ? ltree_foreign_key : :parent_id where(fk => record.id).find_each do |child| rebuild_subtree_ltree_paths!(child) end end |
.syncs_items_on_ltree_change? ⇒ Boolean
Configure item sync behavior
Override in model to customize (e.g., ProductCategory might sync differently)
69 70 71 72 |
# File 'app/concerns/models/ltree_path_builder.rb', line 69 def syncs_items_on_ltree_change? # Default: only ProductLine syncs items name == 'ProductLine' end |
Instance Method Details
#build_ltree_path_ids_value ⇒ Object
Build ltree_path_ids from parent chain
125 126 127 128 129 130 |
# File 'app/concerns/models/ltree_path_builder.rb', line 125 def build_ltree_path_ids_value return nil unless id.present? ids = compute_ltree_ancestor_ids ids.join('.') end |
#build_ltree_path_slugs_value ⇒ Object
Build ltree_path_slugs by looking up names for each ID in the path
133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 |
# File 'app/concerns/models/ltree_path_builder.rb', line 133 def build_ltree_path_slugs_value return nil if ltree_path_ids.blank? # Get all ancestors + self in order ancestor_ids = ltree_path_ids.to_s.split('.').map(&:to_i) # Fetch records and use the name accessor (works with Mobility translations) # We can't use pluck because ltree_name_attribute might be a Mobility accessor (e.g., :name_en) # which doesn't exist as a real database column records = self.class.where(id: ancestor_ids).index_by(&:id) slugs = ancestor_ids.map do |aid| name = if aid == id send(ltree_name_attribute) # Use current (possibly unsaved) name else records[aid]&.send(ltree_name_attribute) end parameterize_for_ltree(name || "item_#{aid}") end slugs.join('.') end |
#build_slug_ltree_value ⇒ Object
Build slug_ltree from FriendlyId slug chain.
Unlike ltree_path_slugs (derived from name_en.parameterize), this uses
the FriendlyId slug attribute which may be manually set (e.g. "powermat"
instead of "snow-melt-powermat-3-in").
e.g. "snow_melting.mat.powermat"
162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 |
# File 'app/concerns/models/ltree_path_builder.rb', line 162 def build_slug_ltree_value return nil if ltree_path_ids.blank? ancestor_ids = ltree_path_ids.to_s.split('.').map(&:to_i) records = self.class.where(id: ancestor_ids).index_by(&:id) labels = ancestor_ids.filter_map do |aid| raw_slug = if aid == id slug else records[aid]&.slug end friendly_slug_to_ltree_label(raw_slug) end return nil if labels.empty? labels.join('.') end |
#compute_ltree_ancestor_ids ⇒ Object
Build ltree_path_ids by walking up the parent_id chain
Returns array of IDs from root to self
99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 |
# File 'app/concerns/models/ltree_path_builder.rb', line 99 def compute_ltree_ancestor_ids ids = [] # For the current record, use parent_id directly (might be unsaved change) # This handles the case where parent_id changed but association is cached ids.unshift(id) if id.present? # Walk up the tree using parent_id values current_parent_id = parent_id visited = Set.new([id]) # Prevent infinite loops from circular references while current_parent_id.present? break if visited.include?(current_parent_id) # Prevent infinite loop visited.add(current_parent_id) ids.unshift(current_parent_id) # Look up the parent's parent_id parent_record = self.class.find_by(id: current_parent_id) current_parent_id = parent_record&.parent_id end ids end |
#derive_cached_ancestor_ids ⇒ Object
Derive cached_ancestor_ids from ltree_path_ids (backward compatibility)
ltree stores root→leaf, cached_ancestor_ids stores leaf→root (excluding self)
184 185 186 187 188 189 190 191 192 |
# File 'app/concerns/models/ltree_path_builder.rb', line 184 def derive_cached_ancestor_ids return [] if ltree_path_ids.blank? ids = ltree_path_ids.to_s.split('.').map(&:to_i) return [] if ids.size <= 1 # Remove self (last element) and reverse to leaf→root order ids[0..-2].reverse end |
#ltree_descendant_ids(use_original_path: false) ⇒ Object
Get all descendant IDs using ltree (efficient)
When called during cascade (after path change), use the ORIGINAL path
to find descendants since they still have the old path prefix in the DB
197 198 199 200 201 202 203 204 205 206 207 208 209 210 |
# File 'app/concerns/models/ltree_path_builder.rb', line 197 def ltree_descendant_ids(use_original_path: false) path_to_use = if use_original_path && ltree_path_ids_before_last_save.present? ltree_path_ids_before_last_save else ltree_path_ids end return [] if path_to_use.blank? self.class .where(self.class.arel_table[:ltree_path_ids].ltree_descendant(path_to_use)) .where.not(id: id) .pluck(:id) end |