AncestryEx.Changeset (ancestry_ex v0.1.0)

Copy Markdown View Source

Changeset functions that keep the tree consistent.

Ecto has no model callbacks. Instead, these functions register Ecto.Changeset.prepare_changes/2 callbacks, which the repo runs inside the same transaction as the insert, update or delete. They take care of:

  • resolving parent_id into the ancestry column (and rejecting cycles),
  • rewriting the ancestry of all descendants when a node moves (a single UPDATE),
  • keeping the depth and counter caches up to date,
  • touching ancestors (touch: true),
  • applying the orphan strategy when a node is deleted.

Saving with Repo.insert/2, Repo.update/2 or Repo.delete/2 without going through these functions bypasses all of the above.

Example

def changeset(category, attrs) do
  category
  |> cast(attrs, [:name])
  |> AncestryEx.Changeset.cast_parent()
end

# create a child, move it, delete its parent
{:ok, child} = Repo.insert(Category.changeset(%Category{}, %{name: "B", parent_id: a.id}))
{:ok, child} = child |> AncestryEx.Changeset.put_parent(other) |> Repo.update()
{:ok, _} = a |> AncestryEx.Changeset.handle_orphans() |> Repo.delete()

Summary

Functions

Casts the parent_id parameter into the ancestry column.

Prepares a node to be deleted with Repo.delete/2, applying the orphan strategy to its descendants inside the delete transaction.

Sets the parent of the node to parent (a persisted node of the same schema) or makes it a root when parent is nil.

Functions

cast_parent(changeset)

@spec cast_parent(Ecto.Changeset.t()) :: Ecto.Changeset.t()

Casts the parent_id parameter into the ancestry column.

Reads "parent_id" (or :parent_id) from the changeset params, or from the changes if :parent_id was already cast. A nil or empty value makes the node a root. Otherwise the parent is loaded when the changeset is saved, and the changeset fails with an error on :parent_id if it does not exist or if it is the node itself or one of its descendants.

It also validates and tracks any change made directly to the ancestry column, so it should be called in every changeset of the schema.

handle_orphans(changeset_or_struct, opts \\ [])

@spec handle_orphans(Ecto.Changeset.t() | struct(), keyword()) :: Ecto.Changeset.t()

Prepares a node to be deleted with Repo.delete/2, applying the orphan strategy to its descendants inside the delete transaction.

The strategy defaults to the :orphan_strategy option of the schema and can be overridden with orphan_strategy: strategy. With :restrict, deleting a node that has children returns {:error, changeset} with an error on the ancestry column.

Accepts a changeset or a struct.

category
|> AncestryEx.Changeset.handle_orphans(orphan_strategy: :adopt)
|> Repo.delete()

put_parent(changeset, parent)

@spec put_parent(Ecto.Changeset.t() | struct(), struct() | nil) :: Ecto.Changeset.t()

Sets the parent of the node to parent (a persisted node of the same schema) or makes it a root when parent is nil.

Accepts a changeset or a struct.