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.

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



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

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

This method returns an undefined value.

Configure ltree path building for this model

Parameters:

  • name_attribute (Symbol) (defaults to: :name)

    attribute used for slug generation (default: :name)

  • cascade_to_descendants (Boolean) (defaults to: true)

    whether to update all descendants when the path changes (default: true)

  • lineage_column (Symbol) (defaults to: :lineage_expanded)

    DB column for the human-readable lineage string (default: :lineage_expanded).
    Source uses :full_name since it has no lineage_expanded column.

  • _deprecated (Hash)

    a customizable set of options

Options Hash (**_deprecated):

  • :legacy (Hash)

    ignored legacy keyword arguments



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

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!void

This method returns an undefined value.

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



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

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

This method returns an undefined value.

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

Parameters:

  • record (ActiveRecord::Base)

    root record of the subtree to rebuild



88
89
90
91
92
93
94
95
96
97
# File 'app/concerns/models/ltree_path_builder.rb', line 88

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)

    true when records of this class sync items on ltree changes



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

Build ltree_path_ids from parent chain

Returns:

  • (String, nil)

    dot-separated ID path, or nil when the record has no ID



130
131
132
133
134
135
# File 'app/concerns/models/ltree_path_builder.rb', line 130

def build_ltree_path_ids_value
  return nil if id.blank?

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

#build_ltree_path_slugs_valueString?

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

Returns:

  • (String, nil)

    dot-separated slug path, or nil when ltree_path_ids is blank



139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
# File 'app/concerns/models/ltree_path_builder.rb', line 139

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

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"

Returns:

  • (String, nil)

    dot-separated FriendlyId slug path, or nil when ltree_path_ids is blank



169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
# File 'app/concerns/models/ltree_path_builder.rb', line 169

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

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

Returns:

  • (Array<Integer>)

    ancestor IDs including self, ordered root to self



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

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

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

Returns:

  • (Array<Integer>)

    ancestor IDs ordered leaf to root, excluding self



192
193
194
195
196
197
198
199
200
# File 'app/concerns/models/ltree_path_builder.rb', line 192

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) ⇒ Array<Integer>

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

Parameters:

  • use_original_path (Boolean) (defaults to: false)

    use the pre-save path to locate descendants (default: false)

Returns:

  • (Array<Integer>)

    IDs of all descendant records



207
208
209
210
211
212
213
214
215
216
217
218
219
220
# File 'app/concerns/models/ltree_path_builder.rb', line 207

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)
      .ids
end