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
Delegated Instance Attributes collapse
-
#descendants_ids ⇒ Object
Alias for Descendants#ids.
-
#self_and_descendants_ids ⇒ Object
Alias for Self_and_descendants#ids.
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').
-
#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_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) .ids 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).ids 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
344 345 346 347 |
# File 'app/concerns/models/ltree_lineage.rb', line 344 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
Alias for Descendants#ids
338 |
# File 'app/concerns/models/ltree_lineage.rb', line 338 delegate :ids, to: :descendants, prefix: true |
#generate_full_name(scope: nil, instance_method: nil) ⇒ Object
Generate full name string from lineage (alias for compatibility with Models::Lineage)
424 425 426 |
# File 'app/concerns/models/ltree_lineage.rb', line 424 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)
429 430 431 |
# File 'app/concerns/models/ltree_lineage.rb', line 429 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
405 406 407 |
# File 'app/concerns/models/ltree_lineage.rb', line 405 def lineage(separator: ' > ', scope: nil, instance_method: nil) lineage_array(scope:, instance_method:).join(separator) end |
#lineage_array(scope: nil, instance_method: nil) ⇒ Object
409 410 411 412 413 414 415 416 417 |
# File 'app/concerns/models/ltree_lineage.rb', line 409 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
419 420 421 |
# File 'app/concerns/models/ltree_lineage.rb', line 419 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)
363 364 365 |
# File 'app/concerns/models/ltree_lineage.rb', line 363 def ltree_slug ltree_slug_path.last end |
#ltree_slug_path ⇒ Object
Parse ltree_path_slugs into an array of slugs
358 359 360 |
# File 'app/concerns/models/ltree_lineage.rb', line 358 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
377 378 379 |
# File 'app/concerns/models/ltree_lineage.rb', line 377 def root self.class.find_by(id: root_id) end |
#root? ⇒ Boolean
Check if this is a root node
382 383 384 385 |
# File 'app/concerns/models/ltree_lineage.rb', line 382 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)
372 373 374 |
# File 'app/concerns/models/ltree_lineage.rb', line 372 def root_id self_and_ancestors_ids.first end |
#self_ancestors_and_descendants_ids ⇒ Object
353 354 355 |
# File 'app/concerns/models/ltree_lineage.rb', line 353 def self_ancestors_and_descendants_ids (ancestors_ids + [id] + descendants_ids).flatten.compact.uniq end |
#self_and_ancestors_ids ⇒ Object
349 350 351 |
# File 'app/concerns/models/ltree_lineage.rb', line 349 def self_and_ancestors_ids ltree_path_ids&.to_s&.split('.')&.map(&:to_i) || [] end |
#self_and_children ⇒ Object
Returns children and self
400 401 402 |
# File 'app/concerns/models/ltree_lineage.rb', line 400 def self_and_children [self] + children.to_a end |
#self_and_descendants_ids ⇒ Object
Alias for Self_and_descendants#ids
340 |
# File 'app/concerns/models/ltree_lineage.rb', line 340 delegate :ids, to: :self_and_descendants, prefix: true |
#self_and_siblings ⇒ Object
Returns self and all siblings
393 394 395 396 397 |
# File 'app/concerns/models/ltree_lineage.rb', line 393 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)
388 389 390 |
# File 'app/concerns/models/ltree_lineage.rb', line 388 def siblings self_and_siblings.where.not(id: id) end |