Organise the records of an Ecto schema as a tree (or a forest of trees) with a materialized path column, like Ruby's ancestry gem, for PostgreSQL.
Every node stores the ids of all its ancestors in a single indexed string column:
id name ancestry
1 Electronics /
2 Phones /1/
3 Android /1/2/
4 Books /With that column, every navigation (parent, ancestors, children, descendants,
siblings, depth) is a single indexed query, and moving a subtree is a single
UPDATE, however large the subtree is.
- Navigation functions return composable
Ecto.Querystructs. - Changeset functions keep the tree consistent inside the repo transaction:
they resolve
parent_id, reject cycles, move descendants and apply orphan strategies. - Optional depth cache (a regular column or a PostgreSQL generated column), counter cache and touching of ancestors.
arrange/1builds nested trees in memory.- Integrity check and repair, and migration from an existing
parent_idcolumn with one recursive query. - Code generators for migrations, schemas and contexts.
Installation
def deps do
[
{:ancestry_ex, "~> 0.1.0"}
]
endRequires Ecto SQL 3.13+ and PostgreSQL. Add :ancestry_ex to import_deps in
your .formatter.exs so ancestry_fields is formatted without parentheses.
Quick start with the generators
# context + schema + migration
$ mix ancestry_ex.gen.context Catalog Category categories name:string --cache-depth --counter-cache
# only schema + migration
$ mix ancestry_ex.gen.schema Catalog.Category categories name:string
# add ancestry to an existing table
$ mix ancestry_ex.gen.migration categories --cache-depth
# convert an existing parent_id tree in place
$ mix ancestry_ex.gen.migration categories --from-parent-id
mix ancestry_ex.gen.context generates:
Catalog.create_category(%{name: "Phones", parent_id: electronics.id})
Catalog.list_root_categories()
Catalog.list_category_children(category)
Catalog.list_category_ancestors(category) # breadcrumbs
Catalog.category_tree() # [{category, [{child, [...]}]}]
Catalog.category_subtree(category)
Catalog.move_category(category, new_parent) # or nil to make it a root
Catalog.update_category(category, %{parent_id: id})
Catalog.delete_category(category) # applies the orphan strategyRun mix help ancestry_ex.gen.context to see all options (orphan strategy,
caches, --binary-id, --table, --format, ...).
Manual setup
Migration
alter table(:categories) do
add :ancestry, :string, null: false, default: "/", collation: "C"
add :ancestry_depth, :integer, null: false, default: 0 # cache_depth: true
add :children_count, :integer, null: false, default: 0 # counter_cache: true
end
create index(:categories, [:ancestry])The "C" collation lets PostgreSQL use the btree index for the
LIKE '/1/2/%' prefix searches used to find descendants.
Schema
defmodule MyApp.Catalog.Category do
use Ecto.Schema
use AncestryEx, orphan_strategy: :rootify, cache_depth: true, counter_cache: 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
endancestry_fields() defines the ancestry column, the cache columns that are
enabled, and a virtual parent_id field.
Options
| Option | Default | Description |
|---|---|---|
:ancestry_column | :ancestry | Column holding the path. |
:ancestry_format | :materialized_path2 | :materialized_path2 (roots store "/") or :materialized_path (roots store NULL, the gem's legacy format). |
:orphan_strategy | :destroy | :destroy, :rootify, :adopt, :restrict or :none. |
:cache_depth | false | true for an integer column, :virtual for a PostgreSQL generated column. |
:depth_cache_column | :ancestry_depth | Name of the depth column. |
:counter_cache | false | true (:children_count) or a column name. |
:touch | false | Bump updated_at of the ancestors when a node is saved or deleted. |
Writing
Ecto has no model callbacks, so the tree is maintained by changeset functions.
They register Ecto.Changeset.prepare_changes/2 callbacks, which the repo runs
in the same transaction as the write.
# create: parent_id comes from the params (string or integer, "" or nil means root)
%Category{} |> Category.changeset(%{"name" => "Phones", "parent_id" => "1"}) |> Repo.insert()
# move a node and its whole subtree (one UPDATE for all descendants)
category |> Category.changeset(%{parent_id: other.id}) |> Repo.update()
category |> AncestryEx.Changeset.put_parent(other) |> Repo.update()
category |> AncestryEx.Changeset.put_parent(nil) |> Repo.update() # make it a root
# delete, applying the orphan strategy of the schema (or another one)
category |> AncestryEx.Changeset.handle_orphans() |> Repo.delete()
category |> AncestryEx.Changeset.handle_orphans(orphan_strategy: :adopt) |> Repo.delete()Validation errors go on :parent_id: "is invalid", "does not exist",
"cannot be the record itself" and "cannot be the record itself or one of its descendants"
(when moving a node under its own subtree). With :restrict, deleting a node
that has children returns {:error, changeset}.
| Orphan strategy | Descendants of a deleted node |
|---|---|
:destroy | Deleted, with one DELETE. |
:rootify | Children become roots and keep their own subtrees. |
:adopt | Children move up to the parent of the deleted node. |
:restrict | The delete fails if the node has children. |
:none | Left untouched (orphaned). |
[!IMPORTANT]
Repo.insert/update/deleteon a struct or changeset that did not go throughcast_parent/1,put_parent/2orhandle_orphans/2skips all of this. In particular,Repo.delete(category)leaves the descendants orphaned.
Reading
Navigation functions are defined in the schema and return queries. Pass a queryable as first argument to scope them, then pipe into the repo:
Category.children(category) |> Repo.all()
Category.descendants(category) |> where(active: true) |> order_by(:name) |> Repo.all()
Category.children(where(Category, active: true), category) |> Repo.all()
Repo.exists?(Category.children(category)) # has children?
Category.ancestors(category) |> Repo.all() # ordered from the root downRuby ancestry | AncestryEx (query) | AncestryEx (no database) |
|---|---|---|
Model.roots | roots/0,1 | root?/1, has_parent?/1 |
parent | parent/1,2 | parent_id/1, parent_of?/2 |
root | root/1,2 | root_id/1, root_of?/2 |
ancestors / ancestors_of | ancestors/1,2 | ancestor_ids/1, ancestor_of?/2 |
path / path_of | path/1,2 | path_ids/1 |
children / children_of | children/1,2 | child_of?/2, child_ancestry/1 |
siblings / siblings_of (excludes self) | siblings/1,2 | sibling_of?/2 |
descendants / descendants_of | descendants/1,2 | descendant_of?/2 |
subtree / subtree_of | subtree/1,2 | in_subtree_of?/2 |
indirects / indirects_of | indirects/1,2 | indirect_of?/2 |
leaves | leaves/0,1 | |
depth | at_depth/1,2 and friends | depth/1 |
ordered_by_ancestry | ordered_by_ancestry/0,1 |
Depth scopes: before_depth/1, to_depth/1, at_depth/1, from_depth/1 and
after_depth/1. They use the depth cache column when there is one, and
compute the depth in SQL otherwise. To get the leaves under a node, compose:
category |> Category.descendants() |> Category.leaves().
The pure helpers are also available in the AncestryEx module
(AncestryEx.parent_id(node)), and every generated function can be overridden.
Trees in memory
Category
|> Category.ordered_by_ancestry()
|> Repo.all()
|> AncestryEx.arrange()
#=> [{%Category{name: "Electronics"}, [{%Category{name: "Phones"}, [...]}]}, ...]
AncestryEx.arrange_serializable(nodes)
#=> [%{id: 1, name: "Electronics", children: [%{id: 2, ...}]}]
AncestryEx.arrange_serializable(nodes, fn node, children ->
%{title: node.name, children: children}
end)
AncestryEx.sort_by_ancestry(nodes) # parents before children (pre-order)Nodes whose parent is not in the list become top-level entries, and siblings keep the order they had in the input, so order the query to sort siblings.
Maintenance
AncestryEx.Integrity.check(Repo, Category)
#=> :ok | {:error, [{:missing_ancestor, 12, 7}, {:cyclic_ancestry, 3}, ...]}
AncestryEx.Integrity.restore(Repo, Category) # repair
AncestryEx.Integrity.build_ancestry_from_parent_ids(Repo, Category) # from a parent_id column
AncestryEx.Integrity.rebuild_depth_cache(Repo, Category)
AncestryEx.Integrity.rebuild_counter_cache(Repo, Category)Migrating from a parent_id column
mix ancestry_ex.gen.migration categories --from-parent-id generates a
migration that adds the columns and fills them from parent_id with a single
recursive CTE. If the schema keeps its parent_id field (for example a
belongs_to :parent), use ancestry_fields(parent_id: false) so the virtual
field is not defined twice.
Differences from the Ruby gem
- No callbacks: writes go through
AncestryEx.Changeset(see above). - Navigation returns
Ecto.Querystructs, not records:Repo.one(Category.parent(node)). - Descendants are always updated with one SQL statement (the gem's
update_strategy: :sql). No callbacks run for them. - Formats:
materialized_path2(default) andmaterialized_path. The gem'smaterialized_path3,ltreeandarrayformats are not supported. cache_depth: :virtualuses a PostgreSQL generated column.
As in the gem, moving a node relies on the ancestry values loaded in memory.
Two concurrent moves in the same subtree can conflict. Serialise them (for
example with SELECT ... FOR UPDATE on the nodes involved) if your application
allows concurrent edits of the tree. AncestryEx.Integrity.restore/3 repairs
the tree if that ever happens.
Development
Tests need a running PostgreSQL. Connection settings are read from PGUSER,
PGPASSWORD, PGHOST and PGPORT (defaults postgres/postgres/localhost/5432).
$ mix deps.get
$ mix test
License
MIT