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
Section titled “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
Section titled “Architecture”Dual Path Strategy
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “Models::Lineage Concern”File: app/concerns/models/lineage.rb
Base concern for hierarchical models using parent_id.
Configuration
Section titled “Configuration”class ProductLine < ApplicationRecord include Models::Lineage acts_as_lineage order: :nameendOptions
Section titled “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
Section titled “Class Methods”Model.parents_only # Records with no parentModel.children_only # Records with a parentModel.roots # Top-level recordsModel.self_and_descendants_ids(id) # ltree-powered (via LtreeLineage)Instance Methods
Section titled “Instance Methods”record.root? # Is this a root record?record.leaf? # Has no children?record.root # Get root ancestorrecord.depth # Tree depth (0 for root)record.ancestors # Parent chainrecord.descendants # All children recursivelyModels::LtreeLineage Concern
Section titled “Models::LtreeLineage Concern”File: app/concerns/models/ltree_lineage.rb
Extends Models::Lineage with ltree-powered query methods.
Configuration
Section titled “Configuration”class ProductLine < ApplicationRecord include Models::Lineage include Models::LtreeLineage
acts_as_lineage order: :name acts_as_ltree_lineageendClass Methods
Section titled “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
Section titled “Instance Methods”record.ltree_descendants # Descendant recordsrecord.ltree_self_and_descendants # Self + descendantsrecord.ltree_ancestors # Ancestor recordsrecord.ltree_self_and_ancestors # Self + ancestorsrecord.ltree_root # Root recordrecord.ltree_siblings # Siblings (same parent)record.ltree_depth # Tree depthrecord.ltree_root? # Is root?record.ltree_leaf? # Is leaf?record.ltree_descendant_of?(other) # Relationship checkrecord.ltree_ancestor_of?(other) # Relationship checkScopes
Section titled “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)
Section titled “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 pathItem.where(Item[:pc_path_slugs].ltree_descendant(LtreePaths::PC_CONTROLS))# => WHERE pc_path_slugs <@ 'goods.controls'::ltree
# Descendant of — array of pathsItem.where(Item[:pc_path_slugs].ltree_descendant(['goods.controls', 'goods.heating_elements']))# => WHERE pc_path_slugs <@ ANY(ARRAY[...]::ltree[])
# NegationItem.where.not(Item[:pc_path_slugs].ltree_descendant(LtreePaths::PC_PUBLICATIONS))# => WHERE NOT (pc_path_slugs <@ 'goods.publications'::ltree)
# Ancestor of — single pathProductLine.where(ProductLine[:ltree_path_ids].ltree_ancestor('1.2.3'))# => WHERE ltree_path_ids @> '1.2.3'::ltree
# Ancestor of — array of pathsProductLine.where(ProductLine[:ltree_path_ids].ltree_ancestor(['1.2', '3.4']))# => WHERE ltree_path_ids @> ANY(ARRAY[...]::ltree[])
# Pattern matching — singleProductLine.where(ProductLine[:ltree_path_slugs].ltree_matches('*.tempzone.*'))# => WHERE ltree_path_slugs ~ '*.tempzone.*'::lquery
# Pattern matching — arrayProductLine.where(ProductLine[:ltree_path_slugs].ltree_matches(['*.tempzone.*', '*.environ.*']))# => WHERE ltree_path_slugs ? ARRAY[...]::lquery[]
# Cross-table via joinProductSpecification.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
Section titled “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)
Section titled “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() functionProductCategory.where.ltree_depth(:ltree_path_ids, 1) # ExactProductCategory.where.ltree_depth(:ltree_path_ids, 2, :gt) # Greater than# Operators: :eq, :gt, :gte, :lt, :lteSELECT Helpers
Section titled “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 aliasORDER Helpers
Section titled “ORDER Helpers”ProductCategory.order_by_ltree_depth(:ltree_path_slugs) # ASCProductCategory.order_by_ltree_depth(:ltree_path_slugs, :desc) # DESCArel Helpers
Section titled “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 endActiveRecordExtended::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
Section titled “LtreePaths Constants”File: app/constants/ltree_paths.rb
Hardcoded slug paths for frequently-used hierarchies. Eliminates database lookups.
Product Categories
Section titled “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
Section titled “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
Section titled “Verification”LtreePaths.verify_all# => ✅ All 39 ltree slug paths verified!Item Model Scopes (Example)
Section titled “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) }endDatabase Triggers
Section titled “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
Section titled “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
Section titled “Performance”Before ltree (Array Overlap)
Section titled “Before ltree (Array Overlap)”-- Slow: Cannot use index efficientlyWHERE inherited_item_product_line_ids && ARRAY[5]::integer[]-- Execution: 7-12 secondsAfter ltree (GiST Index)
Section titled “After ltree (GiST Index)”-- Fast: Uses GiST indexWHERE pc_path_slugs <@ 'goods.towel_warmers'-- Execution: <100msPerformance Comparison
Section titled “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
Section titled “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
Section titled “Testing”mise exec -- bin/rails test test/lib/ltree_query_methods_test.rbKey Files
Section titled “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 |