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.
For 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) ⇒ void
Configure ltree path building for this model.
-
.rebuild_all_ltree_paths! ⇒ void
Rebuild ltree paths for all records (useful for migrations/repairs).
-
.rebuild_subtree_ltree_paths!(record) ⇒ void
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 ⇒ String?
Build ltree_path_ids from parent chain.
-
#build_ltree_path_slugs_value ⇒ String?
Build ltree_path_slugs by looking up names for each ID in the path.
-
#build_slug_ltree_value ⇒ String?
Build slug_ltree from FriendlyId slug chain.
-
#compute_ltree_ancestor_ids ⇒ Array<Integer>
Build ltree_path_ids by walking up the parent_id chain Returns array of IDs from root to self.
-
#derive_cached_ancestor_ids ⇒ Array<Integer>
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) ⇒ Array<Integer>
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)
40 41 42 |
# File 'app/concerns/models/ltree_path_builder.rb', line 40 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) ⇒ void
This method returns an undefined value.
Configure ltree path building for this model
52 53 54 55 56 57 58 59 60 61 62 63 64 |
# File 'app/concerns/models/ltree_path_builder.rb', line 52 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! ⇒ void
This method returns an undefined value.
Rebuild ltree paths for all records (useful for migrations/repairs)
76 77 78 79 80 81 |
# File 'app/concerns/models/ltree_path_builder.rb', line 76 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) ⇒ void
This method returns an undefined value.
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).
88 89 90 91 92 93 94 95 96 97 |
# File 'app/concerns/models/ltree_path_builder.rb', line 88 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 ⇒ String?
Build ltree_path_ids from parent chain
130 131 132 133 134 135 |
# File 'app/concerns/models/ltree_path_builder.rb', line 130 def build_ltree_path_ids_value return nil if id.blank? ids = compute_ltree_ancestor_ids ids.join('.') end |
#build_ltree_path_slugs_value ⇒ String?
Build ltree_path_slugs by looking up names for each ID in the path
139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 |
# File 'app/concerns/models/ltree_path_builder.rb', line 139 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 ⇒ String?
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"
169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 |
# File 'app/concerns/models/ltree_path_builder.rb', line 169 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 ⇒ Array<Integer>
Build ltree_path_ids by walking up the parent_id chain
Returns array of IDs from root to self
103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 |
# File 'app/concerns/models/ltree_path_builder.rb', line 103 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 ⇒ Array<Integer>
Derive cached_ancestor_ids from ltree_path_ids (backward compatibility)
ltree stores root→leaf, cached_ancestor_ids stores leaf→root (excluding self)
192 193 194 195 196 197 198 199 200 |
# File 'app/concerns/models/ltree_path_builder.rb', line 192 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) ⇒ Array<Integer>
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
207 208 209 210 211 212 213 214 215 216 217 218 219 220 |
# File 'app/concerns/models/ltree_path_builder.rb', line 207 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) .ids end |