Packages
ancestry_ex
0.1.0
Organise Ecto schemas into a tree using a materialized path column (a port of Ruby's ancestry gem for PostgreSQL), with code generators.
Current section
Files
Jump to
Current section
Files
ancestry_ex
README.md
README.md
# 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](https://github.com/stefankroes/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.Query` structs.
* 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/1` builds nested trees in memory.
* Integrity check and repair, and migration from an existing `parent_id` column with one recursive query.
* Code generators for migrations, schemas and contexts.
## Installation
```elixir
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
```shell
# 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:
```elixir
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
```elixir
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
```elixir
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.
```elixir
# 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:
```elixir
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
```elixir
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
```elixir
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.Query` structs, 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) and `materialized_path`. The gem's
`materialized_path3`, `ltree` and `array` formats are not supported.
* `cache_depth: :virtual` uses 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`).
```shell
$ mix deps.get
$ mix test
```
## License
MIT