Module: Models::LtreePathBuilder

Extended by:
ActiveSupport::Concern
Included in:
ProductCategory, ProductLine, Source
Defined in:
app/concerns/models/ltree_path_builder.rb

Overview

Models::LtreePathBuilder - Builds ltree paths from parent_id chain

This concern makes ltree the SOURCE OF TRUTH for hierarchy:

parent_id → ltree_path_ids → ltree_path_slugs
↓ ↘
cached_ancestor_ids slug_ltree (FriendlyId slugs)

The flow is:

  1. User sets parent_id (and name)
  2. before_save builds ltree_path_ids by walking parent_id chain
  3. before_save builds ltree_path_slugs from names in the path
  4. before_save generates url from ltree_path_slugs
  5. before_save builds slug_ltree from FriendlyId slug chain (if column exists)
  6. after_commit cascades changes to ALL descendants (not just direct children)

CASCADE BEHAVIOR:
When a record's path changes (parent_id or name), ALL descendants must be updated
because their paths contain this record's path as a prefix.

Example: Floor Heating (id=1) renamed to "Radiant Floor Heating"

  • 1's slug changes: floor_heating → radiant_floor_heating
  • All descendants (1.2, 1.2.102, 1.2.102.103, etc.) need slug updates
  • Their ltree_path_ids stay the same, but slugs and URLs change

Usage:
class ProductLine < ApplicationRecord
include Models::LtreePathBuilder
builds_ltree_path # Enable ltree path building
end

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Instance Attribute Details

#skip_ltree_rebuildObject

Track if path needs rebuilding (to prevent infinite loops)



41
42
43
# File 'app/concerns/models/ltree_path_builder.rb', line 41

def skip_ltree_rebuild
  @skip_ltree_rebuild
end

Class Method Details

.builds_ltree_path(name_attribute: :name, cascade_to_descendants: true, lineage_column: :lineage_expanded, **_deprecated) ⇒ Object

Configure ltree path building for this model

Options:
name_attribute: Attribute used for slug generation (default: :name)
cascade_to_descendants: Whether to update all descendants when path changes (default: true)
lineage_column: DB column for human-readable lineage string (default: :lineage_expanded).
Source uses :full_name since it has no lineage_expanded column.



53
54
55
56
57
58
59
60
61
62
63
64
65
# File 'app/concerns/models/ltree_path_builder.rb', line 53

def builds_ltree_path(name_attribute: :name, cascade_to_descendants: true, lineage_column: :lineage_expanded, **_deprecated)
  class_attribute :ltree_name_attribute, default: name_attribute
  class_attribute :ltree_cascade_to_descendants, default: cascade_to_descendants
  class_attribute :ltree_lineage_column, default: lineage_column

  # Callbacks for building paths
  # IMPORTANT: Use before_validation so paths are built BEFORE validation runs
  # This allows validate_ltree_path_consistency to pass after parent_id changes
  before_validation :build_ltree_paths_from_parent, if: :should_rebuild_ltree_path?
  after_create :rebuild_ltree_after_create
  after_commit :cascade_ltree_to_descendants, if: :should_cascade_to_descendants?
  after_commit :queue_item_ltree_sync, if: :should_sync_items?
end

.rebuild_all_ltree_paths!Object

Rebuild ltree paths for all records (useful for migrations/repairs)



75
76
77
78
79
80
# File 'app/concerns/models/ltree_path_builder.rb', line 75

def rebuild_all_ltree_paths!
  # Process in tree order (roots first, then children)
  roots.find_each do |root|
    rebuild_subtree_ltree_paths!(root)
  end
end

.rebuild_subtree_ltree_paths!(record) ⇒ Object

Rebuild ltree paths for a subtree
Uses parent_id FK directly (not the pg_ltree children method) so this
works even when ltree paths are nil/empty (initial backfill scenario).



85
86
87
88
89
90
91
92
93
94
# File 'app/concerns/models/ltree_path_builder.rb', line 85

def rebuild_subtree_ltree_paths!(record)
  record.skip_ltree_rebuild = false
  record.send(:build_ltree_paths_from_parent)
  record.save!(validate: false)

  fk = respond_to?(:ltree_foreign_key) ? ltree_foreign_key : :parent_id
  where(fk => record.id).find_each do |child|
    rebuild_subtree_ltree_paths!(child)
  end
end

.syncs_items_on_ltree_change?Boolean

Configure item sync behavior
Override in model to customize (e.g., ProductCategory might sync differently)

Returns:

  • (Boolean)


69
70
71
72
# File 'app/concerns/models/ltree_path_builder.rb', line 69

def syncs_items_on_ltree_change?
  # Default: only ProductLine syncs items
  name == 'ProductLine'
end

Instance Method Details

#build_ltree_path_ids_valueObject

Build ltree_path_ids from parent chain



125
126
127
128
129
130
# File 'app/concerns/models/ltree_path_builder.rb', line 125

def build_ltree_path_ids_value
  return nil unless id.present?

  ids = compute_ltree_ancestor_ids
  ids.join('.')
end

#build_ltree_path_slugs_valueObject

Build ltree_path_slugs by looking up names for each ID in the path



133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
# File 'app/concerns/models/ltree_path_builder.rb', line 133

def build_ltree_path_slugs_value
  return nil if ltree_path_ids.blank?

  # Get all ancestors + self in order
  ancestor_ids = ltree_path_ids.to_s.split('.').map(&:to_i)

  # Fetch records and use the name accessor (works with Mobility translations)
  # We can't use pluck because ltree_name_attribute might be a Mobility accessor (e.g., :name_en)
  # which doesn't exist as a real database column
  records = self.class.where(id: ancestor_ids).index_by(&:id)

  slugs = ancestor_ids.map do |aid|
    name = if aid == id
             send(ltree_name_attribute) # Use current (possibly unsaved) name
           else
             records[aid]&.send(ltree_name_attribute)
           end
    parameterize_for_ltree(name || "item_#{aid}")
  end

  slugs.join('.')
end

#build_slug_ltree_valueObject

Build slug_ltree from FriendlyId slug chain.
Unlike ltree_path_slugs (derived from name_en.parameterize), this uses
the FriendlyId slug attribute which may be manually set (e.g. "powermat"
instead of "snow-melt-powermat-3-in").

e.g. "snow_melting.mat.powermat"



162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
# File 'app/concerns/models/ltree_path_builder.rb', line 162

def build_slug_ltree_value
  return nil if ltree_path_ids.blank?

  ancestor_ids = ltree_path_ids.to_s.split('.').map(&:to_i)
  records = self.class.where(id: ancestor_ids).index_by(&:id)

  labels = ancestor_ids.filter_map do |aid|
    raw_slug = if aid == id
                 slug
               else
                 records[aid]&.slug
               end
    friendly_slug_to_ltree_label(raw_slug)
  end

  return nil if labels.empty?

  labels.join('.')
end

#compute_ltree_ancestor_idsObject

Build ltree_path_ids by walking up the parent_id chain
Returns array of IDs from root to self



99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
# File 'app/concerns/models/ltree_path_builder.rb', line 99

def compute_ltree_ancestor_ids
  ids = []

  # For the current record, use parent_id directly (might be unsaved change)
  # This handles the case where parent_id changed but association is cached
  ids.unshift(id) if id.present?

  # Walk up the tree using parent_id values
  current_parent_id = parent_id
  visited = Set.new([id]) # Prevent infinite loops from circular references

  while current_parent_id.present?
    break if visited.include?(current_parent_id) # Prevent infinite loop

    visited.add(current_parent_id)
    ids.unshift(current_parent_id)

    # Look up the parent's parent_id
    parent_record = self.class.find_by(id: current_parent_id)
    current_parent_id = parent_record&.parent_id
  end

  ids
end

#derive_cached_ancestor_idsObject

Derive cached_ancestor_ids from ltree_path_ids (backward compatibility)
ltree stores root→leaf, cached_ancestor_ids stores leaf→root (excluding self)



184
185
186
187
188
189
190
191
192
# File 'app/concerns/models/ltree_path_builder.rb', line 184

def derive_cached_ancestor_ids
  return [] if ltree_path_ids.blank?

  ids = ltree_path_ids.to_s.split('.').map(&:to_i)
  return [] if ids.size <= 1

  # Remove self (last element) and reverse to leaf→root order
  ids[0..-2].reverse
end

#ltree_descendant_ids(use_original_path: false) ⇒ Object

Get all descendant IDs using ltree (efficient)
When called during cascade (after path change), use the ORIGINAL path
to find descendants since they still have the old path prefix in the DB



197
198
199
200
201
202
203
204
205
206
207
208
209
210
# File 'app/concerns/models/ltree_path_builder.rb', line 197

def ltree_descendant_ids(use_original_path: false)
  path_to_use = if use_original_path && ltree_path_ids_before_last_save.present?
                  ltree_path_ids_before_last_save
                else
                  ltree_path_ids
                end

  return [] if path_to_use.blank?

  self.class
      .where(self.class.arel_table[:ltree_path_ids].ltree_descendant(path_to_use))
      .where.not(id: id)
      .pluck(:id)
end