AncestryEx

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.

Installation

def deps do
  [
    {:ancestry_ex, "~> 0.1.0"}
  ]
end

Requires 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 strategy

Run 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
end

ancestry_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/delete on a struct or changeset that did not go through cast_parent/1, put_parent/2 or handle_orphans/2 skips 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 down
Ruby 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

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