Skip to content

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.


ResourceLocation
Lineage Concernapp/concerns/models/lineage.rb
LtreeLineage Concernapp/concerns/models/ltree_lineage.rb
Arel Predicates + Ransackconfig/initializers/arel_ltree_predicates.rb
WhereChain Helperslib/active_record_extended/ltree_query_methods.rb
Path Constantsapp/constants/ltree_paths.rb
Teststest/lib/ltree_query_methods_test.rb

Each hierarchical table maintains TWO ltree columns:

ColumnFormatPurpose
ltree_path_ids'1.2.3'Backward compatibility, numeric lookups
ltree_path_slugs'floor_heating.tempzone'Human-readable, code constants
USER INPUT DERIVED (auto-computed by triggers)
────────── ───────────────────────────────────
parent_id ──────────────────► ltree_path_ids (traversing parent chain)
name ──────────────────► ltree_path_slugs (from slugified names)
Tableltree ColumnsPurpose
product_linesltree_path_ids, ltree_path_slugsProduct hierarchy
product_categoriesltree_path_ids, ltree_path_slugsCategory hierarchy
sourcesltree_path_ids, ltree_path_slugsLead source hierarchy
itemsprimary_pl_path_*, pc_path_*, all_pl_paths_*Denormalized for queries

OperatorNameDescriptionExample
<@Descendant ofLeft is child of right'goods.controls' <@ 'goods' → true
@>Ancestor ofLeft contains right'goods' @> 'goods.controls' → true
~Matches lqueryPattern match with wildcardspath ~ '*.tempzone.*'
?Matches anyMatches any lquery in arraypath ? ARRAY['*.a', '*.b']
FunctionDescriptionExample
nlevel(ltree)Number of labelsnlevel('goods.controls') → 2
subpath(ltree, offset, len)Extract subpathsubpath('goods.controls.thermostats', 0, 2) → ‘goods.controls’
subpath(ltree, offset)Extract to endsubpath('goods.controls', 1) → ‘controls’
lca(ltree, ltree, ...)Longest common ancestorlca('goods.controls', 'goods.sensors') → ‘goods’
index(ltree, ltree)Position of matchindex('goods.controls.thermostats', 'controls') → 1

File: app/concerns/models/lineage.rb

Base concern for hierarchical models using parent_id.

class ProductLine < ApplicationRecord
include Models::Lineage
acts_as_lineage order: :name
end
OptionDefaultDescription
:order:nameColumn for ordering children
:counter_cachetrueMaintain children_count
:foreign_key:parent_idForeign key column
:touch_parentfalseTouch parent on changes
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)
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

File: app/concerns/models/ltree_lineage.rb

Extends Models::Lineage with ltree-powered query methods.

class ProductLine < ApplicationRecord
include Models::Lineage
include Models::LtreeLineage
acts_as_lineage order: :name
acts_as_ltree_lineage
end
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)
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
ProductLine.ltree_descendants_of(record)
ProductLine.ltree_ancestors_of(record)
ProductLine.ltree_self_and_descendants_of(id)
ProductLine.ltree_matching_slug('floor_heating.*')

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

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
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
ProductCategory.order_by_ltree_depth(:ltree_path_slugs) # ASC
ProductCategory.order_by_ltree_depth(:ltree_path_slugs, :desc) # DESC

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)

File: app/constants/ltree_paths.rb

Hardcoded slug paths for frequently-used hierarchies. Eliminates database lookups.

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'
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'
LtreePaths.verify_all
# => ✅ All 39 ltree slug paths verified!

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

PostgreSQL triggers automatically maintain ltree paths on items:

TriggerTableFires OnAction
sync_item_ltree_pathsitemsINSERT/UPDATE of FK columnsSyncs primary_pl_path_* and pc_path_*
sync_item_all_pl_pathsitem_product_linesINSERT/UPDATE/DELETESyncs all_pl_paths_*
cascade_pl_path_to_itemsproduct_linesUPDATE of ltree_path_*Cascades changes to items
cascade_pc_path_to_itemsproduct_categoriesUPDATE of ltree_path_*Cascades changes to items

Located in db/functions/:

FunctionPurpose
sync_item_ltree_paths_v02.sqlSyncs both ID and slug paths from PL/PC
sync_item_all_pl_paths_v02.sqlSyncs array columns from item_product_lines
cascade_pl_path_to_items_v02.sqlCascades ProductLine path changes
cascade_pc_path_to_items_v02.sqlCascades ProductCategory path changes

-- Slow: Cannot use index efficiently
WHERE inherited_item_product_line_ids && ARRAY[5]::integer[]
-- Execution: 7-12 seconds
-- Fast: Uses GiST index
WHERE pc_path_slugs <@ 'goods.towel_warmers'
-- Execution: <100ms
Query TypeBeforeAfterImprovement
Item category scope~50ms<5ms10x faster
URL-based filtering2 queries1 query50% fewer queries
Items by PL descendantsN+1 riskSingle queryEliminated N+1
Path sync on saveRails callbackDB triggerMore reliable
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);

Terminal window
mise exec -- bin/rails test test/lib/ltree_query_methods_test.rb

FilePurpose
app/concerns/models/lineage.rbBase hierarchical model concern
app/concerns/models/ltree_lineage.rbltree query methods for hierarchies
config/initializers/arel_ltree_predicates.rbCanonical Arel predicates + Ransack integration
lib/active_record_extended/ltree_query_methods.rbltree_contains, ltree_depth, SELECT/ORDER helpers
config/initializers/ltree_query_methods.rbLoads the AR extension
app/constants/ltree_paths.rbHardcoded path constants
db/functions/sync_item_ltree_paths_v02.sqlTrigger function for items
db/functions/sync_item_all_pl_paths_v02.sqlTrigger function for item_product_lines
db/functions/cascade_pl_path_to_items_v02.sqlCascade function for product_lines
db/functions/cascade_pc_path_to_items_v02.sqlCascade function for product_categories