Module: Models::LtreeLineage
- Extended by:
- ActiveSupport::Concern
- Included in:
- ProductCategory, ProductLine, Source
- Defined in:
- app/concerns/models/ltree_lineage.rb
Overview
Models::LtreeLineage - PostgreSQL ltree support for hierarchical models
This concern integrates with the pg_ltree gem (https://github.com/sjke/pg_ltree)
to provide efficient tree queries using PostgreSQL's native ltree extension.
== pg_ltree Built-in Methods (used directly)
Instance methods provided by pg_ltree:
- self_and_ancestors, ancestors → ActiveRecord::Relation
- self_and_descendants, descendants → ActiveRecord::Relation
- root, parent → single record
- children, siblings, self_and_siblings → ActiveRecord::Relation
- leaves, leaf? → Relation / boolean
- depth, height → integer
- root? → boolean
== What This Concern Adds
-
Convenience methods that return IDs (for cache compatibility):
- descendants_ids, self_and_descendants_ids
- ancestors_ids, self_and_ancestors_ids
-
Batch class methods (operate on IDs without loading records):
- ProductLine.descendants_ids(1, 2, 3)
- ProductLine.self_and_ancestors_ids(5)
== Usage
class ProductLine < ApplicationRecord
include Models::Lineage # Existing concern (parent_id based)
include Models::LtreeLineage # This concern
acts_as_lineage order: :name
acts_as_ltree_lineage # Enable ltree features
end
Two ltree columns are maintained:
- ltree_path_ids: '1.2.3' (numeric IDs for programmatic queries)
- ltree_path_slugs: 'floor_heating.tempzone.flex_roll' (human-readable)
See: doc/tasks/202512032250_LTREE_HIERARCHY_MIGRATION.md
Belongs to collapse
-
#parent ⇒ Parent
Set up parent/children associations (previously from Models::Lineage).
Has many collapse
Class Method Summary collapse
-
.acts_as_ltree_lineage(path_ids_column: :ltree_path_ids, path_slugs_column: :ltree_path_slugs, foreign_key: :parent_id, order: nil, counter_cache: nil, dependent: :destroy) ⇒ Object
Configure ltree support for this model.
-
.ancestors_ids(id) ⇒ Object
Get ancestor IDs for a single record ID (without loading record) Only needs ONE query to get the path, then parses it - no second query!.
-
.define_ltree_scopes(ids_column, slugs_column) ⇒ Object
Define ActiveRecord scopes for ltree queries.
-
.define_ordered_ltree_methods(ids_column) ⇒ Object
Override pg_ltree's self_and_ancestors/ancestors to add ORDER BY nlevel() pg_ltree returns unordered results which breaks breadcrumbs.
-
.descendants_ids(*ids) ⇒ Object
Get descendant IDs for multiple records (batch operation).
-
.root_ids(*ids) ⇒ Object
Find root IDs for given IDs using ltree subpath function Uses SQL subpath() for efficiency instead of Ruby string parsing.
- .self_ancestors_and_descendants_ids(*ids) ⇒ Object
-
.self_and_ancestors_ids(id) ⇒ Object
Get self and ancestor IDs for a single record ID (without loading record) Only needs ONE query to get the path, then parses it - no second query!.
-
.self_and_descendants_ids(*ids) ⇒ Object
Get self and descendant IDs for multiple records (batch operation).
Instance Method Summary collapse
-
#ancestors_ids ⇒ Object
Parse ancestor IDs directly from the ltree path - no query needed! ltree_path_ids like '1.31.447' contains the full ancestor chain.
-
#descendant_of_path?(path_slug) ⇒ Boolean
Check if this record IS or is a DESCENDANT of a path (slug-based) Uses ltree_path_slugs for the check Example: product_line.descendant_of_path?('floor_heating.tempzone').
-
#descendants_ids ⇒ Object
ID convenience methods Ancestors: Parse directly from ltree path (NO DATABASE QUERY!) Descendants: Must query (we don't know who's below without checking).
-
#generate_full_name(scope: nil, instance_method: nil) ⇒ Object
Generate full name string from lineage (alias for compatibility with Models::Lineage).
-
#generate_full_name_array(scope: nil, instance_method: nil) ⇒ Object
Generate full name array from lineage (alias for compatibility with Models::Lineage).
-
#lineage(separator: ' > ', scope: nil, instance_method: nil) ⇒ Object
Lineage string representation.
- #lineage_array(scope: nil, instance_method: nil) ⇒ Object
- #lineage_simple(instance_method: nil) ⇒ Object
-
#ltree_ancestor_of?(other) ⇒ Boolean
Check if this record is an ancestor of another record.
-
#ltree_descendant_of?(other) ⇒ Boolean
Check if this record is a descendant of another record.
-
#ltree_slug ⇒ Object
Get the slug for this record (last segment of path).
-
#ltree_slug_path ⇒ Object
Parse ltree_path_slugs into an array of slugs.
-
#path_includes?(segment) ⇒ Boolean
Check if a path segment exists anywhere in the ltree path Example: product_line.path_includes?('flex_roll') matches floor_heating.tempzone.flex_roll.*.
-
#root ⇒ Object
Returns the root node.
-
#root? ⇒ Boolean
Check if this is a root node.
-
#root_id ⇒ Object
Returns the root node's ID (first element in ltree path).
- #self_ancestors_and_descendants_ids ⇒ Object
- #self_and_ancestors_ids ⇒ Object
-
#self_and_children ⇒ Object
Returns children and self.
- #self_and_descendants_ids ⇒ Object
-
#self_and_siblings ⇒ Object
Returns self and all siblings.
-
#siblings ⇒ Object
Returns siblings (same parent, excluding self).
Class Method Details
.acts_as_ltree_lineage(path_ids_column: :ltree_path_ids, path_slugs_column: :ltree_path_slugs, foreign_key: :parent_id, order: nil, counter_cache: nil, dependent: :destroy) ⇒ Object
Configure ltree support for this model
Options:
path_ids_column: Column name for ID-based ltree (default: :ltree_path_ids)
path_slugs_column: Column name for slug-based ltree (default: :ltree_path_slugs)
foreign_key: Column for parent reference (default: :parent_id)
order: Order for children association (default: nil)
counter_cache: Counter cache column name (default: nil)
dependent: Dependent option for children (default: :destroy)
68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 |
# File 'app/concerns/models/ltree_lineage.rb', line 68 def acts_as_ltree_lineage(path_ids_column: :ltree_path_ids, path_slugs_column: :ltree_path_slugs, foreign_key: :parent_id, order: nil, counter_cache: nil, dependent: :destroy) # Store column configuration class_attribute :ltree_path_ids_column, default: path_ids_column class_attribute :ltree_path_slugs_column, default: path_slugs_column class_attribute :ltree_foreign_key, default: foreign_key # Set up parent/children associations (previously from Models::Lineage) belongs_to :parent, class_name: name, foreign_key: foreign_key, counter_cache: counter_cache, inverse_of: :children, optional: true has_many :children, -> { order(order) if order }, class_name: name, foreign_key: foreign_key, dependent: dependent, inverse_of: :parent # Scopes for root/children filtering scope :roots, -> { where(foreign_key => nil).then { |r| order ? r.order(order) : r } } scope :parents_only, -> { where(foreign_key => nil) } scope :children_only, -> { where.not(foreign_key => nil) } # Use pg_ltree gem for the ID-based path column # Disable cascade callbacks since our triggers handle path updates ltree path_ids_column, cascade_update: false, cascade_destroy: false if respond_to?(:ltree) # Define scopes for ltree queries define_ltree_scopes(path_ids_column, path_slugs_column) # Override pg_ltree's unordered methods with properly ordered versions # Must be done AFTER ltree() call since pg_ltree defines methods there define_ordered_ltree_methods(path_ids_column) end |
.ancestors_ids(id) ⇒ Object
Get ancestor IDs for a single record ID (without loading record)
Only needs ONE query to get the path, then parses it - no second query!
222 223 224 225 226 227 228 |
# File 'app/concerns/models/ltree_lineage.rb', line 222 def ancestors_ids(id) path = where(id: id).pick(ltree_path_ids_column) return [] if path.blank? path_ids = path.to_s.split('.').map(&:to_i) path_ids[0...-1] end |
.define_ltree_scopes(ids_column, slugs_column) ⇒ Object
Define ActiveRecord scopes for ltree queries
149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 |
# File 'app/concerns/models/ltree_lineage.rb', line 149 def define_ltree_scopes(ids_column, slugs_column) # Find all records that are descendants of any of the given IDs # Uses ltree <@ operator with GiST index scope :ltree_descendants_of, ->(record_or_ids) { paths = resolve_ltree_paths(record_or_ids, ids_column) return none if paths.empty? where("#{ids_column} <@ ANY(ARRAY[?]::ltree[])", paths) } # Find all records that are ancestors of the given record/ID scope :ltree_ancestors_of, ->(record_or_id) { path = resolve_ltree_path(record_or_id, ids_column) return none if path.blank? where("#{ids_column} @> ?", path).where.not(id: resolve_id(record_or_id)) } # Find all records that are descendants OR the records themselves scope :ltree_self_and_descendants_of, ->(record_or_ids) { paths = resolve_ltree_paths(record_or_ids, ids_column) return none if paths.empty? where("#{ids_column} <@ ANY(ARRAY[?]::ltree[])", paths) } # Find records matching a slug pattern (using lquery) # Example: ProductLine.ltree_matching_slug('floor_heating.*') scope :ltree_matching_slug, ->(pattern) { where("#{slugs_column} ~ ?", pattern) } # Find records where any slug matches the pattern scope :ltree_matching_any_slug, ->(patterns) { where("#{slugs_column} ? ARRAY[?]::lquery[]", patterns) } end |
.define_ordered_ltree_methods(ids_column) ⇒ Object
Override pg_ltree's self_and_ancestors/ancestors to add ORDER BY nlevel()
pg_ltree returns unordered results which breaks breadcrumbs
IMPORTANT: Legacy Models::Lineage returned SELF-FIRST order (most specific to least).
Code like self_and_ancestors.reverse_each depends on this for breadcrumbs.
116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 |
# File 'app/concerns/models/ltree_lineage.rb', line 116 def define_ordered_ltree_methods(ids_column) # self_and_ancestors: ordered from self to root (most specific first) # Example: [TempZone Flex Roll, TempZone, Floor Heating] define_method(:self_and_ancestors) do self.class.where("#{ids_column} @> ?", send(ids_column)) .order(Arel.sql("nlevel(#{ids_column}) DESC")) end # ancestors: ordered from parent to root (excludes self) define_method(:ancestors) do self_and_ancestors.where.not(id: id) end # self_and_descendants: ordered by level (self first, then children, etc.) define_method(:self_and_descendants) do self.class.where("#{ids_column} <@ ?", send(ids_column)) .order(Arel.sql("nlevel(#{ids_column})")) end # descendants: ordered by level (excludes self) define_method(:descendants) do self_and_descendants.where.not(id: id) end end |
.descendants_ids(*ids) ⇒ Object
Get descendant IDs for multiple records (batch operation)
197 198 199 200 201 202 203 204 205 206 207 |
# File 'app/concerns/models/ltree_lineage.rb', line 197 def descendants_ids(*ids) ids = [ids].flatten.compact.uniq return [] if ids.empty? paths = where(id: ids).pluck(ltree_path_ids_column).compact return [] if paths.empty? where("#{ltree_path_ids_column} <@ ANY(ARRAY[?]::ltree[])", paths) .where.not(id: ids) .pluck(:id) end |
.root_ids(*ids) ⇒ Object
Find root IDs for given IDs using ltree subpath function
Uses SQL subpath() for efficiency instead of Ruby string parsing
241 242 243 244 245 246 247 248 249 250 |
# File 'app/concerns/models/ltree_lineage.rb', line 241 def root_ids(*ids) ids = [ids].flatten.compact.uniq return [] if ids.empty? where(id: ids) .where.not(ltree_path_ids_column => nil) .pluck(Arel.sql("subpath(#{ltree_path_ids_column}, 0, 1)::text")) .map(&:to_i) .uniq end |
.self_ancestors_and_descendants_ids(*ids) ⇒ Object
141 142 143 144 145 146 |
# File 'app/concerns/models/ltree_lineage.rb', line 141 def self_ancestors_and_descendants_ids(*ids) ids = [ids].flatten.compact.uniq return [] if ids.empty? (ancestors_ids(*ids) + ids + descendants_ids(*ids)).flatten.compact.uniq end |
.self_and_ancestors_ids(id) ⇒ Object
Get self and ancestor IDs for a single record ID (without loading record)
Only needs ONE query to get the path, then parses it - no second query!
232 233 234 235 236 237 |
# File 'app/concerns/models/ltree_lineage.rb', line 232 def self_and_ancestors_ids(id) path = where(id: id).pick(ltree_path_ids_column) return [] if path.blank? path.to_s.split('.').map(&:to_i) end |
.self_and_descendants_ids(*ids) ⇒ Object
Get self and descendant IDs for multiple records (batch operation)
210 211 212 213 214 215 216 217 218 |
# File 'app/concerns/models/ltree_lineage.rb', line 210 def self_and_descendants_ids(*ids) ids = [ids].flatten.compact.uniq return [] if ids.empty? paths = where(id: ids).pluck(ltree_path_ids_column).compact return [] if paths.empty? where("#{ltree_path_ids_column} <@ ANY(ARRAY[?]::ltree[])", paths).pluck(:id) end |
Instance Method Details
#ancestors_ids ⇒ Object
Parse ancestor IDs directly from the ltree path - no query needed!
ltree_path_ids like '1.31.447' contains the full ancestor chain
348 349 350 351 |
# File 'app/concerns/models/ltree_lineage.rb', line 348 def ancestors_ids path_ids = ltree_path_ids&.to_s&.split('.')&.map(&:to_i) || [] path_ids[0...-1] end |
#children ⇒ ActiveRecord::Relation<Child>
87 88 89 90 91 92 |
# File 'app/concerns/models/ltree_lineage.rb', line 87 has_many :children, -> { order(order) if order }, class_name: name, foreign_key: foreign_key, dependent: dependent, inverse_of: :parent |
#descendant_of_path?(path_slug) ⇒ Boolean
Check if this record IS or is a DESCENDANT of a path (slug-based)
Uses ltree_path_slugs for the check
Example: product_line.descendant_of_path?('floor_heating.tempzone')
318 319 320 321 322 323 |
# File 'app/concerns/models/ltree_lineage.rb', line 318 def descendant_of_path?(path_slug) return false if ltree_path_slugs.blank? slugs = ltree_path_slugs.to_s slugs == path_slug || slugs.start_with?("#{path_slug}.") end |
#descendants_ids ⇒ Object
ID convenience methods
Ancestors: Parse directly from ltree path (NO DATABASE QUERY!)
Descendants: Must query (we don't know who's below without checking)
338 339 340 |
# File 'app/concerns/models/ltree_lineage.rb', line 338 def descendants_ids descendants.pluck(:id) end |
#generate_full_name(scope: nil, instance_method: nil) ⇒ Object
Generate full name string from lineage (alias for compatibility with Models::Lineage)
428 429 430 |
# File 'app/concerns/models/ltree_lineage.rb', line 428 def generate_full_name(scope: nil, instance_method: nil) lineage(scope:, instance_method:) end |
#generate_full_name_array(scope: nil, instance_method: nil) ⇒ Object
Generate full name array from lineage (alias for compatibility with Models::Lineage)
433 434 435 |
# File 'app/concerns/models/ltree_lineage.rb', line 433 def generate_full_name_array(scope: nil, instance_method: nil) lineage_array(scope:, instance_method:) end |
#lineage(separator: ' > ', scope: nil, instance_method: nil) ⇒ Object
Lineage string representation
409 410 411 |
# File 'app/concerns/models/ltree_lineage.rb', line 409 def lineage(separator: ' > ', scope: nil, instance_method: nil) lineage_array(scope:, instance_method:).join(separator) end |
#lineage_array(scope: nil, instance_method: nil) ⇒ Object
413 414 415 416 417 418 419 420 421 |
# File 'app/concerns/models/ltree_lineage.rb', line 413 def lineage_array(scope: nil, instance_method: nil) instance_method ||= :name lines = ancestors.to_a lines = lines.select(&scope) if scope.is_a?(Proc) lines = lines.send(scope) if scope.is_a?(Symbol) # Reverse since ancestors returns self-first order lines = lines.reverse.map { |l| l.send(instance_method) } lines << send(instance_method) end |
#lineage_simple(instance_method: nil) ⇒ Object
423 424 425 |
# File 'app/concerns/models/ltree_lineage.rb', line 423 def lineage_simple(instance_method: nil) lineage(separator: '-', instance_method:) end |
#ltree_ancestor_of?(other) ⇒ Boolean
Check if this record is an ancestor of another record
311 312 313 |
# File 'app/concerns/models/ltree_lineage.rb', line 311 def ltree_ancestor_of?(other) other.ltree_descendant_of?(self) end |
#ltree_descendant_of?(other) ⇒ Boolean
Check if this record is a descendant of another record
303 304 305 306 307 308 |
# File 'app/concerns/models/ltree_lineage.rb', line 303 def ltree_descendant_of?(other) return false if ltree_path.blank? || other.ltree_path.blank? return false if id == other.id ltree_path.to_s.start_with?("#{other.ltree_path}.") end |
#ltree_slug ⇒ Object
Get the slug for this record (last segment of path)
367 368 369 |
# File 'app/concerns/models/ltree_lineage.rb', line 367 def ltree_slug ltree_slug_path.last end |
#ltree_slug_path ⇒ Object
Parse ltree_path_slugs into an array of slugs
362 363 364 |
# File 'app/concerns/models/ltree_lineage.rb', line 362 def ltree_slug_path ltree_path_slugs&.to_s&.split('.') || [] end |
#parent ⇒ Parent
Set up parent/children associations (previously from Models::Lineage)
80 81 82 83 84 85 |
# File 'app/concerns/models/ltree_lineage.rb', line 80 belongs_to :parent, class_name: name, foreign_key: foreign_key, counter_cache: counter_cache, inverse_of: :children, optional: true |
#path_includes?(segment) ⇒ Boolean
Check if a path segment exists anywhere in the ltree path
Example: product_line.path_includes?('flex_roll') matches floor_heating.tempzone.flex_roll.*
327 328 329 330 331 332 |
# File 'app/concerns/models/ltree_lineage.rb', line 327 def path_includes?(segment) return false if ltree_path_slugs.blank? slugs = ltree_path_slugs.to_s slugs == segment || slugs.start_with?("#{segment}.") || slugs.include?(".#{segment}.") || slugs.end_with?(".#{segment}") end |
#root ⇒ Object
Returns the root node
381 382 383 |
# File 'app/concerns/models/ltree_lineage.rb', line 381 def root self.class.find_by(id: root_id) end |
#root? ⇒ Boolean
Check if this is a root node
386 387 388 389 |
# File 'app/concerns/models/ltree_lineage.rb', line 386 def root? fk = self.class.ltree_foreign_key || :parent_id send(fk).blank? end |
#root_id ⇒ Object
Returns the root node's ID (first element in ltree path)
376 377 378 |
# File 'app/concerns/models/ltree_lineage.rb', line 376 def root_id self_and_ancestors_ids.first end |
#self_ancestors_and_descendants_ids ⇒ Object
357 358 359 |
# File 'app/concerns/models/ltree_lineage.rb', line 357 def self_ancestors_and_descendants_ids (ancestors_ids + [id] + descendants_ids).flatten.compact.uniq end |
#self_and_ancestors_ids ⇒ Object
353 354 355 |
# File 'app/concerns/models/ltree_lineage.rb', line 353 def self_and_ancestors_ids ltree_path_ids&.to_s&.split('.')&.map(&:to_i) || [] end |
#self_and_children ⇒ Object
Returns children and self
404 405 406 |
# File 'app/concerns/models/ltree_lineage.rb', line 404 def self_and_children [self] + children.to_a end |
#self_and_descendants_ids ⇒ Object
342 343 344 |
# File 'app/concerns/models/ltree_lineage.rb', line 342 def self_and_descendants_ids self_and_descendants.pluck(:id) end |
#self_and_siblings ⇒ Object
Returns self and all siblings
397 398 399 400 401 |
# File 'app/concerns/models/ltree_lineage.rb', line 397 def self_and_siblings fk = self.class.ltree_foreign_key || :parent_id parent_value = send(fk) parent_value.present? ? self.class.where(fk => parent_value) : self.class.roots end |
#siblings ⇒ Object
Returns siblings (same parent, excluding self)
392 393 394 |
# File 'app/concerns/models/ltree_lineage.rb', line 392 def siblings self_and_siblings.where.not(id: id) end |