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)
Belongs to collapse
-
#parent ⇒ Parent
Set up parent/children associations (previously from Models::Lineage).
Has many collapse
-
#children ⇒ ActiveRecord::Relation<Child>
Child records ordered by the configured order column, if any.
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) ⇒ void
Configure ltree support for this model.
-
.ancestors_ids(id) ⇒ Array<Integer>
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) ⇒ void
Define ActiveRecord scopes for ltree queries.
-
.define_ordered_ltree_methods(ids_column) ⇒ void
Override pg_ltree's self_and_ancestors/ancestors to add ORDER BY nlevel() pg_ltree returns unordered results which breaks breadcrumbs.
-
.descendants_ids(*ids) ⇒ Array<Integer>
Get descendant IDs for multiple records (batch operation).
-
.root_ids(*ids) ⇒ Array<Integer>
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) ⇒ Array<Integer>
Combined ancestor, self, and descendant IDs for multiple records.
-
.self_and_ancestors_ids(id) ⇒ Array<Integer>
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) ⇒ Array<Integer>
Get self and descendant IDs for multiple records (batch operation).
Instance Method Summary collapse
-
#ancestors_ids ⇒ Array<Integer>
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.
-
#generate_full_name(scope: nil, instance_method: nil) ⇒ String
Generate full name string from lineage (alias for compatibility with Models::Lineage).
-
#generate_full_name_array(scope: nil, instance_method: nil) ⇒ Array
Generate full name array from lineage (alias for compatibility with Models::Lineage).
-
#lineage(separator: ' > ', scope: nil, instance_method: nil) ⇒ String
Lineage string representation.
-
#lineage_array(scope: nil, instance_method: nil) ⇒ Array
Lineage as an array of labels, root first, self last.
-
#lineage_simple(instance_method: nil) ⇒ String
Lineage string with a simple '-' separator.
-
#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 ⇒ String?
Get the slug for this record (last segment of path).
-
#ltree_slug_path ⇒ Array<String>
Parse ltree_path_slugs into an array of slugs.
-
#path_includes?(segment) ⇒ Boolean
Check if a path segment exists anywhere in the ltree path.
-
#root ⇒ ActiveRecord::Base?
Returns the root node.
-
#root? ⇒ Boolean
Check if this is a root node.
-
#root_id ⇒ Integer?
Returns the root node's ID (first element in ltree path).
-
#self_ancestors_and_descendants_ids ⇒ Array<Integer>
Combined ancestor, self, and descendant IDs for this record.
-
#self_and_ancestors_ids ⇒ Array<Integer>
Self and ancestor IDs parsed directly from the ltree path - no query needed!.
-
#self_and_children ⇒ Array<ActiveRecord::Base>
Returns children and self.
-
#self_and_siblings ⇒ ActiveRecord::Relation
Returns self and all siblings.
-
#siblings ⇒ ActiveRecord::Relation
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) ⇒ void
This method returns an undefined value.
Configure ltree support for this model
66 67 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 |
# File 'app/concerns/models/ltree_lineage.rb', line 66 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 # Child records ordered by the configured order column, if any 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) ⇒ Array<Integer>
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!
235 236 237 238 239 240 241 |
# File 'app/concerns/models/ltree_lineage.rb', line 235 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) ⇒ void
This method returns an undefined value.
Define ActiveRecord scopes for ltree queries
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 186 187 188 189 190 191 192 |
# File 'app/concerns/models/ltree_lineage.rb', line 156 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) ⇒ void
Legacy Models::Lineage returned SELF-FIRST order (most specific to least).
This method returns an undefined value.
Override pg_ltree's self_and_ancestors/ancestors to add ORDER BY nlevel()
pg_ltree returns unordered results which breaks breadcrumbs
Code like self_and_ancestors.reverse_each depends on this for breadcrumbs.
117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 |
# File 'app/concerns/models/ltree_lineage.rb', line 117 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) ⇒ Array<Integer>
Get descendant IDs for multiple records (batch operation)
206 207 208 209 210 211 212 213 214 215 216 |
# File 'app/concerns/models/ltree_lineage.rb', line 206 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) ⇒ Array<Integer>
Find root IDs for given IDs using ltree subpath function
Uses SQL subpath() for efficiency instead of Ruby string parsing
258 259 260 261 262 263 264 265 266 267 |
# File 'app/concerns/models/ltree_lineage.rb', line 258 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) ⇒ Array<Integer>
Combined ancestor, self, and descendant IDs for multiple records
145 146 147 148 149 150 |
# File 'app/concerns/models/ltree_lineage.rb', line 145 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) ⇒ Array<Integer>
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!
247 248 249 250 251 252 |
# File 'app/concerns/models/ltree_lineage.rb', line 247 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) ⇒ Array<Integer>
Get self and descendant IDs for multiple records (batch operation)
221 222 223 224 225 226 227 228 229 |
# File 'app/concerns/models/ltree_lineage.rb', line 221 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 ⇒ Array<Integer>
Parse ancestor IDs directly from the ltree path - no query needed!
ltree_path_ids like '1.31.447' contains the full ancestor chain
386 387 388 389 |
# File 'app/concerns/models/ltree_lineage.rb', line 386 def ancestors_ids path_ids = ltree_path_ids&.to_s&.split('.')&.map(&:to_i) || [] path_ids[0...-1] end |
#children ⇒ ActiveRecord::Relation<Child>
Child records ordered by the configured order column, if any
86 87 88 89 90 91 |
# File 'app/concerns/models/ltree_lineage.rb', line 86 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
355 356 357 358 359 360 |
# File 'app/concerns/models/ltree_lineage.rb', line 355 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
379 |
# File 'app/concerns/models/ltree_lineage.rb', line 379 delegate :ids, to: :descendants, prefix: true |
#generate_full_name(scope: nil, instance_method: nil) ⇒ String
Generate full name string from lineage (alias for compatibility with Models::Lineage)
492 493 494 |
# File 'app/concerns/models/ltree_lineage.rb', line 492 def generate_full_name(scope: nil, instance_method: nil) lineage(scope:, instance_method:) end |
#generate_full_name_array(scope: nil, instance_method: nil) ⇒ Array
Generate full name array from lineage (alias for compatibility with Models::Lineage)
500 501 502 |
# File 'app/concerns/models/ltree_lineage.rb', line 500 def generate_full_name_array(scope: nil, instance_method: nil) lineage_array(scope:, instance_method:) end |
#lineage(separator: ' > ', scope: nil, instance_method: nil) ⇒ String
Lineage string representation
463 464 465 |
# File 'app/concerns/models/ltree_lineage.rb', line 463 def lineage(separator: ' > ', scope: nil, instance_method: nil) lineage_array(scope:, instance_method:).join(separator) end |
#lineage_array(scope: nil, instance_method: nil) ⇒ Array
Lineage as an array of labels, root first, self last
471 472 473 474 475 476 477 478 479 |
# File 'app/concerns/models/ltree_lineage.rb', line 471 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) ⇒ String
Lineage string with a simple '-' separator
484 485 486 |
# File 'app/concerns/models/ltree_lineage.rb', line 484 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
344 345 346 |
# File 'app/concerns/models/ltree_lineage.rb', line 344 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
334 335 336 337 338 339 |
# File 'app/concerns/models/ltree_lineage.rb', line 334 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 ⇒ String?
Get the slug for this record (last segment of path)
411 412 413 |
# File 'app/concerns/models/ltree_lineage.rb', line 411 def ltree_slug ltree_slug_path.last end |
#ltree_slug_path ⇒ Array<String>
Parse ltree_path_slugs into an array of slugs
405 406 407 |
# File 'app/concerns/models/ltree_lineage.rb', line 405 def ltree_slug_path ltree_path_slugs&.to_s&.split('.') || [] end |
#parent ⇒ Parent
Set up parent/children associations (previously from Models::Lineage)
78 79 80 81 82 83 |
# File 'app/concerns/models/ltree_lineage.rb', line 78 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
368 369 370 371 372 373 |
# File 'app/concerns/models/ltree_lineage.rb', line 368 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 ⇒ ActiveRecord::Base?
Returns the root node
427 428 429 |
# File 'app/concerns/models/ltree_lineage.rb', line 427 def root self.class.find_by(id: root_id) end |
#root? ⇒ Boolean
Check if this is a root node
433 434 435 436 |
# File 'app/concerns/models/ltree_lineage.rb', line 433 def root? fk = self.class.ltree_foreign_key || :parent_id send(fk).blank? end |
#root_id ⇒ Integer?
Returns the root node's ID (first element in ltree path)
421 422 423 |
# File 'app/concerns/models/ltree_lineage.rb', line 421 def root_id self_and_ancestors_ids.first end |
#self_ancestors_and_descendants_ids ⇒ Array<Integer>
Combined ancestor, self, and descendant IDs for this record
399 400 401 |
# File 'app/concerns/models/ltree_lineage.rb', line 399 def self_ancestors_and_descendants_ids (ancestors_ids + [id] + descendants_ids).flatten.compact.uniq end |
#self_and_ancestors_ids ⇒ Array<Integer>
Self and ancestor IDs parsed directly from the ltree path - no query needed!
393 394 395 |
# File 'app/concerns/models/ltree_lineage.rb', line 393 def self_and_ancestors_ids ltree_path_ids&.to_s&.split('.')&.map(&:to_i) || [] end |
#self_and_children ⇒ Array<ActiveRecord::Base>
Returns children and self
454 455 456 |
# File 'app/concerns/models/ltree_lineage.rb', line 454 def self_and_children [self] + children.to_a end |
#self_and_descendants_ids ⇒ Object
Alias for Self_and_descendants#ids
381 |
# File 'app/concerns/models/ltree_lineage.rb', line 381 delegate :ids, to: :self_and_descendants, prefix: true |
#self_and_siblings ⇒ ActiveRecord::Relation
Returns self and all siblings
446 447 448 449 450 |
# File 'app/concerns/models/ltree_lineage.rb', line 446 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 ⇒ ActiveRecord::Relation
Returns siblings (same parent, excluding self)
440 441 442 |
# File 'app/concerns/models/ltree_lineage.rb', line 440 def siblings self_and_siblings.where.not(id: id) end |