-
-
Notifications
You must be signed in to change notification settings - Fork 16
LTree
Reads PostgreSQL's ltree data type as an Array of labels and picks the right tree
operator when querying it. A label path is how you store a hierarchy in a single
column, which makes it a natural fit for categories, taxonomies and permissions.
PostgreSQL Docs
Note
ltreeis an extension, so it has to be enabled before any of this works:enable_extension 'ltree'in a migration.
A path is an Array of labels, so it flows through Ruby like any other list, and an
ltree[] column simply becomes an Array of Arrays. The value class adds the cheap
path operations on top, so comparing two loaded records never needs a round trip to
the database.
Conditions are not a plain =. The value decides: a plain path is compared with
=, and anything carrying an lquery feature is matched with ~.
Use ltree as the type of the column, with array: true for a list of paths.
Tree operators are only indexed by GiST.
enable_extension 'ltree'
create_table "categories" do |t|
t.string "title"
t.ltree "path", index: { using: :gist }
end
create_table "users" do |t|
t.ltree "permissions", array: true
endThere is also lquery, for the rare case of storing a pattern in a column.
add_column :rules, :pattern, :lqueryThe column is automatically identified and its value turned into a path.
category.path # => ["Top", "Science"]
category.path.to_s # => "Top.Science"
category.path.depth # => 2
category.path.root # => ["Top"]
category.path.root? # => false
category.path.parent # => ["Top"]
category.path / 'Astro' # => ["Top", "Science", "Astro"]
category.path.lca(other) # The longest common ancestor
category.path.index_of('Science')Two paths can be compared without touching the database. Both checks include the
path itself, exactly like the @> and <@ operators.
a.ancestor_of?(b) # Or a.covers?(b)
a.descendant_of?(b) # Or a.covered_by?(b)The value can be set as a string, an array or symbols:
category.path = 'Top.Science'
category.path = %w[Top Science]
category.path = [:Top, :Science]Labels are limited to letters, numbers, underscores and dashes, and anything else
raises an ArgumentError rather than reaching the database as a syntax error.
Dashes are only accepted as of PostgreSQL 16, which is what
[ltree.sanitize]({{ site.baseurl }}/getting-started/configuring/#ltree.sanitize)
is for.
A tree is usually built out of the records themselves, so a record stands for the value of its primary key wherever a label is accepted.
category.path = [parent, child] # Stored as "1.2"
category.path = ['app', parent] # Records and labels mix freely
category.path / child # Extending a pathThat covers every operation, not just assignment, so the comparisons, lca,
index_of, the patterns and the conditions all take records too.
parent_path.ancestor_of?(child)
category.path.index_of(child)
Category.where(path: [parent, child]) # path = '1.2'
Category.where(path: [parent, :any]) # path ~ '1.*'
Category.where(path: parent) # path = '1'It reads the primary key through id, so a model with a custom primary key uses
that column instead. A record that has not been saved yet, or one with a composite
primary key, raises an ArgumentError rather than producing a broken path.
Note A UUID primary key contains dashes, which PostgreSQL only accepts in a label as of version 16. On anything older, set [
ltree.sanitize]({{ site.baseurl }}/getting-started/configuring/#ltree.sanitize) to{ '-' => '_' }.
Any object that responds to to_tree_path is asked for its own path, which is the
way to decide what a class means as a path instead of relying on its primary key.
The method can return a string, an array of labels, or another path.
class Category < ActiveRecord::Base
def to_tree_path
[parent&.to_tree_path, id].compact
end
end
document.path = category # The path the category describes
Category.where(path: category) # Same, as a condition
Category.where(path: [category, :any])
# Everything below it
category_path.ancestor_of?(other) # And while comparing two pathsIt takes precedence over the primary key, so a record that defines the method
describes itself rather than being reduced to its id. When the method describes a
pattern instead of a path, the condition becomes a ~ match on its own:
Category.where(path: something) # path ~ 'app.*' when to_tree_path says soThe method name comes from
[ltree.compatible_method]({{ site.baseurl }}/getting-started/configuring/#ltree.compatible_method),
and setting it to nil turns the behavior off.
An lquery is a pattern that matches paths. It is built from Ruby, one entry per
item of the pattern.
| PostgreSQL | Ruby | Meaning |
|---|---|---|
Top |
'Top' or :Top
|
A literal label |
* |
:any |
Zero or more labels |
*{2} |
2..2 |
Exactly 2 labels |
*{0,2} |
0..2 |
Between 0 and 2 labels |
*{1,} |
1.. |
At least 1 label |
*{,3} |
..3 |
At most 3 labels |
sport* |
'sport*' |
Prefix match |
sport@ |
'sport@' |
Case-insensitive |
sport% |
'sport%' |
Matches initial underscore-separated words |
sport*@ |
'sport*@' |
Modifiers combine |
football|tennis |
%w[football tennis] |
Alternatives |
!football|tennis |
'!football|tennis' |
A negated group |
football{1,} |
'football{1,}' |
A quantified item |
Two rules cover the structure: a Range is a quantified star, and a nested Array
is a group of alternatives. Everything else is written on the string exactly as it
appears in SQL. The example from the PostgreSQL manual:
['Top', 0..2, 'sport*@', '!football|tennis{1,}', %w[Russ* Spain]]
# => Top.*{0,2}.sport*@.!football|tennis{1,}.Russ*|SpainNote Patterns are only ever built, never parsed back. A pattern read from an
lquerycolumn keeps its text form untouched, and there is no Ruby object model describing its parts.
A value without any pattern feature is a plain path, so it is compared with = and
find_by, uniqueness validations and associations keep working as usual. As soon as
the value carries a pattern feature, the condition becomes a ~ match.
Category.where(path: 'Top.Science') # path = 'Top.Science'
Category.where(path: %w[Top Science]) # path = 'Top.Science'
Category.where(path: ['Top', :any]) # path ~ 'Top.*'
Category.where(path: ['Top', 1..]) # path ~ 'Top.*{1,}'
Category.where(path: ['Top', %w[a b]]) # path ~ 'Top.a|b'
Category.where(path: 'Top.*.sport*@') # path ~ 'Top.*.sport*@'A star matches zero labels too, which means Top.* matches Top itself and is
exactly the same as <@ 'Top'. GiST indexes both, so the whole subtree query is
just a pattern. Use *{1,} when the path itself should be left out.
Note On a path column
where(path: [a, b])is not anIN. An Array is always a single value here: at the top level it is the sequence of items, and nested it is a group of alternatives. To test a path against several whole patterns, use the?operator below.
The remaining operators are [Arel]({{ site.baseurl }}/querying/arel/) attribute
methods, and PostgreSQL defines all of them for ltree[] columns as well, where
they mean that some entry of the array matches.
path = Category.arel_table['path']
path.contains(value) # path @> ? Is it an ancestor of the value
path.contained_by(value) # path <@ ? Is it a descendant of the value
path.matches_lquery(value) # path ~ ? Does it match the pattern
path.matches_any_lquery(value) # path ? ? Does it match any of the patternsLabels that come from somewhere else rarely satisfy PostgreSQL's rules on their own,
and a slug with dashes only works as of PostgreSQL 16. Set
[ltree.sanitize]({{ site.baseurl }}/getting-started/configuring/#ltree.sanitize)
to normalize them before they are validated:
Torque::PostgreSQL.configure do |config|
config.ltree.sanitize = { '-' => '_' }
end
category.path = 'top.my-slug' # Stored as top.my_slugIt applies to the labels the application provides, including the labels inside a pattern, and never to values read from the database.
Can't find what you're looking for? Add an issue to the issue tracker.