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: doc/tasks/202512032250_LTREE_HIERARCHY_MIGRATION.md

Belongs to collapse

Has many 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) ⇒ 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_idsObject

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

#childrenActiveRecord::Relation<Child>

Returns:

  • (ActiveRecord::Relation<Child>)

See Also:



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')

Returns:

  • (Boolean)


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_idsObject

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

Returns:

  • (Boolean)


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

Returns:

  • (Boolean)


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_slugObject

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_pathObject

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

#parentParent

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

Returns:

  • (Parent)

See Also:



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.*

Returns:

  • (Boolean)


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

#rootObject

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

Returns:

  • (Boolean)


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_idObject

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_idsObject



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_idsObject



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_childrenObject

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_idsObject



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_siblingsObject

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

#siblingsObject

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