ltree Hierarchy System
PostgreSQL's ltree extension provides efficient hierarchical queries with human-readable slug-based paths (e.g., 'goods.towel_warmers') for better code readability and stability.
Quick Reference
| Resource | Location |
|---|---|
| Lineage Concern | app/concerns/models/lineage.rb |
| LtreeLineage Concern | app/concerns/models/ltree_lineage.rb |
| Arel Predicates + Ransack | config/initializers/arel_ltree_predicates.rb |
| WhereChain Helpers | lib/active_record_extended/ltree_query_methods.rb |
| Path Constants | app/constants/ltree_paths.rb |
| Tests | test/lib/ltree_query_methods_test.rb |
Architecture
Dual Path Strategy
Each hierarchical table maintains TWO ltree columns:
| Column | Format | Purpose |
|---|---|---|
ltree_path_ids |
'1.2.3' |
Backward compatibility, numeric lookups |
ltree_path_slugs |
'floor_heating.tempzone' |
Human-readable, code constants |
Data Flow
USER INPUT DERIVED (auto-computed by triggers)
────────── ───────────────────────────────────
parent_id ──────────────────► ltree_path_ids (traversing parent chain)
name ──────────────────► ltree_path_slugs (from slugified names)
Tables Using ltree
| Table | ltree Columns | Purpose |
|---|---|---|
product_lines |
ltree_path_ids, ltree_path_slugs |
Product hierarchy |
product_categories |
ltree_path_ids, ltree_path_slugs |
Category hierarchy |
sources |
ltree_path_ids, ltree_path_slugs |
Lead source hierarchy |
items |
primary_pl_path_*, pc_path_*, all_pl_paths_* |
Denormalized for queries |
ltree Operators
| Operator | Name | Description | Example |
|---|---|---|---|
<@ |
Descendant of | Left is child of right | 'goods.controls' <@ 'goods' → true |
@> |
Ancestor of | Left contains right | 'goods' @> 'goods.controls' → true |
~ |
Matches lquery | Pattern match with wildcards | path ~ '*.tempzone.*' |
? |
Matches any | Matches any lquery in array | path ? ARRAY['*.a', '*.b'] |
ltree Functions
| Function | Description | Example |
|---|---|---|
nlevel(ltree) |
Number of labels | nlevel('goods.controls') → 2 |
subpath(ltree, offset, len) |
Extract subpath | subpath('goods.controls.thermostats', 0, 2) → 'goods.controls' |
subpath(ltree, offset) |
Extract to end | subpath('goods.controls', 1) → 'controls' |
lca(ltree, ltree, ...) |
Longest common ancestor | lca('goods.controls', 'goods.sensors') → 'goods' |
index(ltree, ltree) |
Position of match | index('goods.controls.thermostats', 'controls') → 1 |
Models::Lineage Concern
File: app/concerns/models/lineage.rb
Base concern for hierarchical models using parent_id.
Configuration
class ProductLine < ApplicationRecord
include Models::Lineage
acts_as_lineage order: :name
end
Options
| Option | Default | Description |
|---|---|---|
:order |
:name |
Column for ordering children |
:counter_cache |
true |
Maintain children_count |
:foreign_key |
:parent_id |
Foreign key column |
:touch_parent |
false |
Touch parent on changes |
Class Methods
Model.parents_only # Records with no parent
Model.children_only # Records with a parent
Model.roots # Top-level records
Model.self_and_descendants_ids(id) # ltree-powered (via LtreeLineage)
Instance Methods
record.root? # Is this a root record?
record.leaf? # Has no children?
record.root # Get root ancestor
record.depth # Tree depth (0 for root)
record.ancestors # Parent chain
record.descendants # All children recursively
Models::LtreeLineage Concern
File: app/concerns/models/ltree_lineage.rb
Extends Models::Lineage with ltree-powered query methods.
Configuration
class ProductLine < ApplicationRecord
include Models::Lineage
include Models::LtreeLineage
acts_as_lineage order: :name
acts_as_ltree_lineage
end
Class Methods
ProductLine.descendants_ids(5) # [6, 7, 8, ...]
ProductLine.self_and_descendants_ids(5) # [5, 6, 7, 8, ...]
ProductLine.ancestors_ids(8) # [5, 1] (child-to-root)
ProductLine.self_and_ancestors_ids(8) # [8, 5, 1]
ProductLine.root_ids(8, 9) # [1, 1] (roots for each)
Instance Methods
record.ltree_descendants # Descendant records
record.ltree_self_and_descendants # Self + descendants
record.ltree_ancestors # Ancestor records
record.ltree_self_and_ancestors # Self + ancestors
record.ltree_root # Root record
record.ltree_siblings # Siblings (same parent)
record.ltree_depth # Tree depth
record.ltree_root? # Is root?
record.ltree_leaf? # Is leaf?
record.ltree_descendant_of?(other) # Relationship check
record.ltree_ancestor_of?(other) # Relationship check
Scopes
ProductLine.ltree_descendants_of(record)
ProductLine.ltree_ancestors_of(record)
ProductLine.ltree_self_and_descendants_of(id)
ProductLine.ltree_matching_slug('floor_heating.*')
Query API — Arel Predicates (Canonical)
File: config/initializers/arel_ltree_predicates.rb
All ltree WHERE clause operators (<@, @>, ~) use Arel predicates via Model[:column]:
# Descendant of — single path
Item.where(Item[:pc_path_slugs].ltree_descendant(LtreePaths::PC_CONTROLS))
# => WHERE pc_path_slugs <@ 'goods.controls'::ltree
# Descendant of — array of paths
Item.where(Item[:pc_path_slugs].ltree_descendant(['goods.controls', 'goods.heating_elements']))
# => WHERE pc_path_slugs <@ ANY(ARRAY[...]::ltree[])
# Negation
Item.where.not(Item[:pc_path_slugs].ltree_descendant(LtreePaths::PC_PUBLICATIONS))
# => WHERE NOT (pc_path_slugs <@ 'goods.publications'::ltree)
# Ancestor of — single path
ProductLine.where(ProductLine[:ltree_path_ids].ltree_ancestor('1.2.3'))
# => WHERE ltree_path_ids @> '1.2.3'::ltree
# Ancestor of — array of paths
ProductLine.where(ProductLine[:ltree_path_ids].ltree_ancestor(['1.2', '3.4']))
# => WHERE ltree_path_ids @> ANY(ARRAY[...]::ltree[])
# Pattern matching — single
ProductLine.where(ProductLine[:ltree_path_slugs].ltree_matches('*.tempzone.*'))
# => WHERE ltree_path_slugs ~ '*.tempzone.*'::lquery
# Pattern matching — array
ProductLine.where(ProductLine[:ltree_path_slugs].ltree_matches(['*.tempzone.*', '*.environ.*']))
# => WHERE ltree_path_slugs ? ARRAY[...]::lquery[]
# Cross-table via join
ProductSpecification.joins(:product_line)
.where(ProductLine[:ltree_path_ids].ltree_descendant(paths))
# In concerns (model-agnostic — use arel_table)
where(arel_table[:pc_path_slugs].ltree_descendant(LtreePaths::PC_CONTROLS))
Ransack Integration
Item.ransack(pc_path_slugs_ltree_descendant: 'goods.controls').result
<%= search_form_for @q do |f| %>
<%= f.select :pc_path_slugs_ltree_descendant,
[['Floor Heating', 'goods.heating_elements'],
['Controls', 'goods.controls']],
include_blank: 'All Categories' %>
<% end %>
WhereChain Helpers (Kept)
File: lib/active_record_extended/ltree_query_methods.rb
These methods use ltree functions with no simple Arel equivalent:
# Contains check — uses index() function
# Checks if any of the given IDs appear as labels in the ltree path(s)
ViewProductCatalog.where.ltree_contains(:all_pl_paths_ids, [1, 2])
ProductLine.where.ltree_contains(:ltree_path_ids, '102', array: false)
# Depth queries — uses nlevel() function
ProductCategory.where.ltree_depth(:ltree_path_ids, 1) # Exact
ProductCategory.where.ltree_depth(:ltree_path_ids, 2, :gt) # Greater than
# Operators: :eq, :gt, :gte, :lt, :lte
SELECT Helpers
ProductCategory.select_ltree_depth(:ltree_path_ids)
# => each record has .ltree_path_ids_depth
ProductCategory.select_ltree_root(:ltree_path_slugs)
# => each record has .ltree_path_slugs_root
ProductCategory.select_ltree_parent(:ltree_path_slugs)
# => each record has .ltree_path_slugs_parent
ProductCategory.select_ltree_depth(:ltree_path_ids, 'tree_depth')
# => custom alias
ORDER Helpers
ProductCategory.order_by_ltree_depth(:ltree_path_slugs) # ASC
ProductCategory.order_by_ltree_depth(:ltree_path_slugs, :desc) # DESC
Arel Helpers
For composing complex SQL:
ActiveRecordExtended::Ltree.nlevel(:path) # nlevel(path)
ActiveRecordExtended::Ltree.subpath(:path, 0, 1) # subpath(path, 0, 1)
ActiveRecordExtended::Ltree.subpath(:path, 1) # subpath(path, 1) - to end
ActiveRecordExtended::Ltree.lca(:path1, :path2) # lca(path1, path2)
ActiveRecordExtended::Ltree.index(:path, 'goods') # index(path, 'goods')
ActiveRecordExtended::Ltree.text2ltree('goods.controls')
ActiveRecordExtended::Ltree.ltree2text(:path)
LtreePaths Constants
File: app/constants/ltree_paths.rb
Hardcoded slug paths for frequently-used hierarchies. Eliminates database lookups.
Product Categories
LtreePaths::PC_GOODS # 'goods'
LtreePaths::PC_SERVICES # 'services'
LtreePaths::PC_HEATING_ELEMENTS # 'goods.heating_elements'
LtreePaths::PC_CONTROLS # 'goods.controls'
LtreePaths::PC_CONTROLS_THERMOSTATS # 'goods.controls.thermostats'
LtreePaths::PC_ACCESSORIES # 'goods.accessories'
LtreePaths::PC_TOWEL_WARMERS # 'goods.towel_warmers'
LtreePaths::PC_MIRRORS # 'goods.mirrors'
LtreePaths::PC_RADIANT_PANELS # 'goods.radiant_panels'
LtreePaths::PC_POWER # 'goods.power'
LtreePaths::PC_SENSORS # 'goods.sensors'
LtreePaths::PC_PUBLICATIONS # 'goods.publications'
Product Lines
LtreePaths::PL_FLOOR_HEATING # 'floor_heating'
LtreePaths::PL_FLOOR_HEATING_TEMPZONE # 'floor_heating.tempzone'
LtreePaths::PL_FLOOR_HEATING_CONTROL # 'floor_heating.control'
LtreePaths::PL_FLOOR_HEATING_CONTROL_SMARTSTAT # 'floor_heating.control.smartstat'
LtreePaths::PL_SNOW_MELTING # 'snow_melting'
LtreePaths::PL_SNOW_MELTING_CONTROL # 'snow_melting.control'
LtreePaths::PL_TOWEL_WARMER # 'towel_warmer'
LtreePaths::PL_RADIANT_PANEL # 'radiant_panel'
LtreePaths::PL_LED_MIRROR # 'led_mirror'
Verification
LtreePaths.verify_all
# => ✅ All 39 ltree slug paths verified!
Item Model Scopes (Example)
class Item < ApplicationRecord
scope :heating_elements, -> { where(Item[:pc_path_slugs].ltree_descendant(LtreePaths::PC_HEATING_ELEMENTS)) }
scope :controls, -> { where(Item[:pc_path_slugs].ltree_descendant(LtreePaths::PC_CONTROLS)) }
scope :towel_warmers, -> { where(Item[:pc_path_slugs].ltree_descendant(LtreePaths::PC_TOWEL_WARMERS)) }
scope :goods, -> { where(Item[:pc_path_slugs].ltree_descendant(LtreePaths::PC_GOODS)) }
scope :non_publications, -> { where.not(Item[:pc_path_slugs].ltree_descendant(LtreePaths::PC_PUBLICATIONS)) }
scope :floor_heating_controls, -> {
controls.where(Item[:primary_pl_path_slugs].ltree_descendant(LtreePaths::PL_FLOOR_HEATING_CONTROL))
}
scope :tempzones, -> {
where(Item[:primary_pl_path_slugs].ltree_descendant(LtreePaths::PL_FLOOR_HEATING_TEMPZONE)).merge(heating_elements)
}
end
Database Triggers
PostgreSQL triggers automatically maintain ltree paths on items:
| Trigger | Table | Fires On | Action |
|---|---|---|---|
sync_item_ltree_paths |
items |
INSERT/UPDATE of FK columns | Syncs primary_pl_path_* and pc_path_* |
sync_item_all_pl_paths |
item_product_lines |
INSERT/UPDATE/DELETE | Syncs all_pl_paths_* |
cascade_pl_path_to_items |
product_lines |
UPDATE of ltree_path_* |
Cascades changes to items |
cascade_pc_path_to_items |
product_categories |
UPDATE of ltree_path_* |
Cascades changes to items |
Trigger Functions
Located in db/functions/:
| Function | Purpose |
|---|---|
sync_item_ltree_paths_v02.sql |
Syncs both ID and slug paths from PL/PC |
sync_item_all_pl_paths_v02.sql |
Syncs array columns from item_product_lines |
cascade_pl_path_to_items_v02.sql |
Cascades ProductLine path changes |
cascade_pc_path_to_items_v02.sql |
Cascades ProductCategory path changes |
Performance
Before ltree (Array Overlap)
-- Slow: Cannot use index efficiently
WHERE inherited_item_product_line_ids && ARRAY[5]::integer[]
-- Execution: 7-12 seconds
After ltree (GiST Index)
-- Fast: Uses GiST index
WHERE pc_path_slugs <@ 'goods.towel_warmers'
-- Execution: <100ms
Performance Comparison
| Query Type | Before | After | Improvement |
|---|---|---|---|
| Item category scope | ~50ms | <5ms | 10x faster |
| URL-based filtering | 2 queries | 1 query | 50% fewer queries |
| Items by PL descendants | N+1 risk | Single query | Eliminated N+1 |
| Path sync on save | Rails callback | DB trigger | More reliable |
Indexes
CREATE INDEX index_items_on_primary_pl_path_slugs ON items USING gist(primary_pl_path_slugs);
CREATE INDEX index_items_on_pc_path_slugs ON items USING gist(pc_path_slugs);
CREATE INDEX index_items_on_all_pl_paths_slugs ON items USING gist(all_pl_paths_slugs);
CREATE INDEX idx_product_lines_ltree_path_ids ON product_lines USING gist(ltree_path_ids);
CREATE INDEX idx_product_lines_ltree_path_slugs ON product_lines USING gist(ltree_path_slugs);
CREATE INDEX idx_product_categories_ltree_path_ids ON product_categories USING gist(ltree_path_ids);
CREATE INDEX idx_product_categories_ltree_path_slugs ON product_categories USING gist(ltree_path_slugs);
Testing
mise exec -- bin/rails test test/lib/ltree_query_methods_test.rb
Key Files
| File | Purpose |
|---|---|
app/concerns/models/lineage.rb |
Base hierarchical model concern |
app/concerns/models/ltree_lineage.rb |
ltree query methods for hierarchies |
config/initializers/arel_ltree_predicates.rb |
Canonical Arel predicates + Ransack integration |
lib/active_record_extended/ltree_query_methods.rb |
ltree_contains, ltree_depth, SELECT/ORDER helpers |
config/initializers/ltree_query_methods.rb |
Loads the AR extension |
app/constants/ltree_paths.rb |
Hardcoded path constants |
db/functions/sync_item_ltree_paths_v02.sql |
Trigger function for items |
db/functions/sync_item_all_pl_paths_v02.sql |
Trigger function for item_product_lines |
db/functions/cascade_pl_path_to_items_v02.sql |
Cascade function for product_lines |
db/functions/cascade_pc_path_to_items_v02.sql |
Cascade function for product_categories |