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

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

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

#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

Alias for Descendants#ids

Returns:

  • (Object)

    Descendants#descendants_ids

See Also:



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

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)



363
364
365
# File 'app/concerns/models/ltree_lineage.rb', line 363

def ltree_slug
  ltree_slug_path.last
end

#ltree_slug_pathObject

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

#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



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

Returns:

  • (Boolean)


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_idObject

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_idsObject



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_idsObject



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_childrenObject

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_idsObject

Alias for Self_and_descendants#ids

Returns:

  • (Object)

    Self_and_descendants#self_and_descendants_ids

See Also:



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

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

#self_and_siblingsObject

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

#siblingsObject

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