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

  1. Convenience methods that return IDs (for cache compatibility):

    • descendants_ids, self_and_descendants_ids
    • ancestors_ids, self_and_ancestors_ids
  2. 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 Also:

  • doc/tasks/202512032250_LTREE_HIERARCHY_MIGRATIONdoc/tasks/202512032250_LTREE_HIERARCHY_MIGRATION.md

Belongs to collapse

Has many collapse

Delegated Instance Attributes collapse

Class Method Summary collapse

Instance Method Summary collapse

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

Parameters:

  • path_ids_column (Symbol) (defaults to: :ltree_path_ids)

    column name for ID-based ltree (default: :ltree_path_ids)

  • path_slugs_column (Symbol) (defaults to: :ltree_path_slugs)

    column name for slug-based ltree (default: :ltree_path_slugs)

  • foreign_key (Symbol) (defaults to: :parent_id)

    column for parent reference (default: :parent_id)

  • order (Symbol, nil) (defaults to: nil)

    order for children association (default: nil)

  • counter_cache (Symbol, nil) (defaults to: nil)

    counter cache column name (default: nil)

  • dependent (Symbol) (defaults to: :destroy)

    dependent option for children (default: :destroy)



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!

Parameters:

  • id (Integer)

    record ID whose ancestors are wanted

Returns:

  • (Array<Integer>)

    ancestor IDs from root to parent



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

Parameters:

  • ids_column (Symbol)

    ID-based ltree path column

  • slugs_column (Symbol)

    slug-based ltree path column



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

Note:

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.

Parameters:

  • ids_column (Symbol)

    ID-based ltree path column to order by



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)

Parameters:

  • ids (Array<Integer>)

    record IDs whose descendants are wanted

Returns:

  • (Array<Integer>)

    descendant IDs, excluding the given IDs



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

Parameters:

  • ids (Array<Integer>)

    record IDs whose roots are wanted

Returns:

  • (Array<Integer>)

    unique root IDs



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

Parameters:

  • ids (Array<Integer>)

    record IDs to expand

Returns:

  • (Array<Integer>)

    unique IDs of ancestors, the records, and descendants



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!

Parameters:

  • id (Integer)

    record ID to include along with ancestors

Returns:

  • (Array<Integer>)

    IDs from root to the record itself



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)

Parameters:

  • ids (Array<Integer>)

    record IDs to include along with descendants

Returns:

  • (Array<Integer>)

    the given IDs plus all descendant IDs



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_idsArray<Integer>

Parse ancestor IDs directly from the ltree path - no query needed!
ltree_path_ids like '1.31.447' contains the full ancestor chain

Returns:

  • (Array<Integer>)

    ancestor IDs from root to parent



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

#childrenActiveRecord::Relation<Child>

Child records ordered by the configured order column, if any

Returns:

  • (ActiveRecord::Relation<Child>)

See Also:



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

Examples:

product_line.descendant_of_path?('floor_heating.tempzone')

Parameters:

  • path_slug (String)

    dot-separated slug path to test against

Returns:

  • (Boolean)

    true when this record's slug path is at or below +path_slug+



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_idsObject

Alias for Descendants#ids

Returns:

  • (Object)

    Descendants#descendants_ids

See Also:



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)

Parameters:

  • scope (Proc, Symbol, nil) (defaults to: nil)

    optional filter/projection applied to ancestors

  • instance_method (Symbol, nil) (defaults to: nil)

    method called on each record for its label (default: :name)

Returns:

  • (String)

    lineage labels joined with the default separator



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)

Parameters:

  • scope (Proc, Symbol, nil) (defaults to: nil)

    optional filter/projection applied to ancestors

  • instance_method (Symbol, nil) (defaults to: nil)

    method called on each record for its label (default: :name)

Returns:

  • (Array)

    lineage labels from root to this record



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

Parameters:

  • separator (String) (defaults to: ' > ')

    string placed between lineage entries

  • scope (Proc, Symbol, nil) (defaults to: nil)

    optional filter/projection applied to ancestors

  • instance_method (Symbol, nil) (defaults to: nil)

    method called on each record for its label (default: :name)

Returns:

  • (String)

    ancestor labels joined by +separator+, ending with this record



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

Parameters:

  • scope (Proc, Symbol, nil) (defaults to: nil)

    optional filter/projection applied to ancestors

  • instance_method (Symbol, nil) (defaults to: nil)

    method called on each record for its label (default: :name)

Returns:

  • (Array)

    labels from root to this record



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

Parameters:

  • instance_method (Symbol, nil) (defaults to: nil)

    method called on each record for its label (default: :name)

Returns:

  • (String)

    ancestor labels joined with '-', ending with this record



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

Parameters:

  • other (ActiveRecord::Base)

    the potential descendant record

Returns:

  • (Boolean)

    true when +other+ sits below this record in the tree



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

Parameters:

  • other (ActiveRecord::Base)

    the potential ancestor record

Returns:

  • (Boolean)

    true when this record sits below +other+ in the tree



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_slugString?

Get the slug for this record (last segment of path)

Returns:

  • (String, nil)

    this record's slug, or nil when no slug path is set



411
412
413
# File 'app/concerns/models/ltree_lineage.rb', line 411

def ltree_slug
  ltree_slug_path.last
end

#ltree_slug_pathArray<String>

Parse ltree_path_slugs into an array of slugs

Returns:

  • (Array<String>)

    slugs from root to this record



405
406
407
# File 'app/concerns/models/ltree_lineage.rb', line 405

def ltree_slug_path
  ltree_path_slugs&.to_s&.split('.') || []
end

#parentParent

Set up parent/children associations (previously from Models::Lineage)

Returns:

  • (Parent)

See Also:



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

Examples:

product_line.path_includes?('flex_roll') # matches floor_heating.tempzone.flex_roll.*

Parameters:

  • segment (String)

    single slug segment to look for

Returns:

  • (Boolean)

    true when +segment+ appears as a full path component



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

#rootActiveRecord::Base?

Returns the root node

Returns:

  • (ActiveRecord::Base, nil)

    the root record, or nil when not found



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

Returns:

  • (Boolean)

    true when the record has no parent



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_idInteger?

Returns the root node's ID (first element in ltree path)

Returns:

  • (Integer, nil)

    root ID, or nil when no ltree path is set



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_idsArray<Integer>

Combined ancestor, self, and descendant IDs for this record

Returns:

  • (Array<Integer>)

    unique IDs of ancestors, self, and descendants



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_idsArray<Integer>

Self and ancestor IDs parsed directly from the ltree path - no query needed!

Returns:

  • (Array<Integer>)

    IDs from root to this record



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_childrenArray<ActiveRecord::Base>

Returns children and self

Returns:

  • (Array<ActiveRecord::Base>)

    this record followed by its children



454
455
456
# File 'app/concerns/models/ltree_lineage.rb', line 454

def self_and_children
  [self] + children.to_a
end

#self_and_descendants_idsObject

Alias for Self_and_descendants#ids

Returns:

  • (Object)

    Self_and_descendants#self_and_descendants_ids

See Also:



381
# File 'app/concerns/models/ltree_lineage.rb', line 381

delegate :ids, to: :self_and_descendants, prefix: true

#self_and_siblingsActiveRecord::Relation

Returns self and all siblings

Returns:

  • (ActiveRecord::Relation)

    records sharing this record's parent



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

#siblingsActiveRecord::Relation

Returns siblings (same parent, excluding self)

Returns:

  • (ActiveRecord::Relation)

    sibling records



440
441
442
# File 'app/concerns/models/ltree_lineage.rb', line 440

def siblings
  self_and_siblings.where.not(id: id)
end