Organise the records of an Ecto schema as a tree (or a forest of trees) using a materialized path column, like Ruby's ancestry gem.
Every node stores the ids of all its ancestors in a single string column
("/1/5/12/"), so every navigation (parent, ancestors, children,
descendants, siblings, depth...) is a single indexed query, and moving a
subtree is a single UPDATE.
Setup
defmodule MyApp.Catalog.Category do
use Ecto.Schema
use AncestryEx, orphan_strategy: :rootify, cache_depth: true
import Ecto.Changeset
schema "categories" do
field :name, :string
ancestry_fields()
timestamps()
end
def changeset(category, attrs) do
category
|> cast(attrs, [:name])
|> validate_required([:name])
|> AncestryEx.Changeset.cast_parent()
end
endmix ancestry_ex.gen.migration adds the columns to an existing table, and
mix ancestry_ex.gen.schema / mix ancestry_ex.gen.context generate a new
schema (and context) from scratch.
Options
:ancestry_column- name of the column, defaults to:ancestry.:ancestry_format-:materialized_path2(default) or:materialized_path. SeeAncestryEx.Format.:orphan_strategy- what happens to the descendants of a deleted node (seeAncestryEx.Changeset.handle_orphans/2)::destroy(default) - delete them.:rootify- the children become roots.:adopt- the children are attached to the parent of the deleted node.:restrict- refuse to delete a node with children.:none- do nothing.
:cache_depth-trueto keep the depth of every node in an integer column,:virtualto use a PostgreSQL generated column instead. Defaults tofalse; depth scopes still work, computing it in SQL.:depth_cache_column- defaults to:ancestry_depth.:counter_cache-true(column:children_count) or a column name to keep the number of children of every node.:touch- whentrue, ancestors get their autoupdated timestamps (updated_at) bumped whenever a descendant is saved or deleted.
Generated functions
use AncestryEx defines these functions in the schema. Query functions
take an optional queryable as first argument and return an Ecto.Query.
| Query | Pure (no database) |
|---|---|
roots/0,1 | root?/1, has_parent?/1 |
parent/1,2 | parent_id/1, parent_of?/2 |
root/1,2 | root_id/1, root_of?/2 |
ancestors/1,2 | ancestor_ids/1, ancestor_of?/2 |
path/1,2 | path_ids/1 |
children/1,2 | child_of?/2, child_ancestry/1 |
siblings/1,2 | sibling_of?/2 |
descendants/1,2 | descendant_of?/2 |
subtree/1,2 | in_subtree_of?/2 |
indirects/1,2 | indirect_of?/2 |
leaves/0,1 | depth/1 |
before_depth/1,2, to_depth/1,2, at_depth/1,2, from_depth/1,2, after_depth/1,2 | |
ordered_by_ancestry/0,1 |
All of them can be overridden. The pure functions are also available
directly in this module (AncestryEx.parent_id(node)), together with the
tree helpers arrange/1, arrange_serializable/2 and sort_by_ancestry/1.
Summary
Functions
Ids of the ancestors of node, root first.
Returns true if ancestor is an ancestor of node.
Defines the ancestry fields inside a schema block.
Arranges a flat list of nodes into a nested tree.
Arranges nodes like arrange/1 and converts each node into a map with a
:children key, ready to be encoded as JSON.
Ancestry value of the children of node. The node must be persisted.
Returns true if node is a child of parent.
Depth of node in its tree, roots have depth 0.
Returns true if node is a descendant of ancestor.
Returns true if node has a parent.
Returns true if node is ancestor or one of its descendants.
Returns true if node is a descendant of ancestor but not one of its children.
Id of the parent of node, nil for a root.
Returns true if parent is the parent of node.
Ids of the ancestors of node plus its own id, root first.
Returns true if node is a root.
Id of the root of the tree node belongs to (its own id for a root).
Returns true if root is the root of the tree node belongs to.
Returns true if a and b are different nodes sharing the same parent.
Sorts nodes so that every parent comes before its children (pre-order), keeping the relative order of siblings.
Functions
Ids of the ancestors of node, root first.
Returns true if ancestor is an ancestor of node.
Defines the ancestry fields inside a schema block.
- the ancestry column (
:string, defaulting to the root value), - the depth cache column when
:cache_depthis enabled, - the counter cache column when
:counter_cacheis enabled, - a virtual
:parent_idfield used byAncestryEx.Changeset.cast_parent/1.
Pass parent_id: false to skip the virtual field, for example when the
table still has a real parent_id column mapped in the schema.
Arranges a flat list of nodes into a nested tree.
Returns a list of {node, children} tuples where children has the same
shape. Nodes whose parent is not in the list become top-level entries and
siblings keep the order they have in the input.
Category.subtree(category)
|> Category.ordered_by_ancestry()
|> Repo.all()
|> AncestryEx.arrange()
#=> [{%Category{name: "Root"}, [{%Category{name: "Child"}, []}]}]
Arranges nodes like arrange/1 and converts each node into a map with a
:children key, ready to be encoded as JSON.
A function (node, children) -> term can be given to build each entry.
Ancestry value of the children of node. The node must be persisted.
Returns true if node is a child of parent.
Depth of node in its tree, roots have depth 0.
Returns true if node is a descendant of ancestor.
Returns true if node has a parent.
Returns true if node is ancestor or one of its descendants.
Returns true if node is a descendant of ancestor but not one of its children.
Id of the parent of node, nil for a root.
Returns true if parent is the parent of node.
Ids of the ancestors of node plus its own id, root first.
Returns true if node is a root.
Id of the root of the tree node belongs to (its own id for a root).
Returns true if root is the root of the tree node belongs to.
Returns true if a and b are different nodes sharing the same parent.
Sorts nodes so that every parent comes before its children (pre-order), keeping the relative order of siblings.