defmodule CTE do
@moduledoc """
The Closure Table for Elixir strategy, CTE for short,
is a simple and elegant way of storing and working with
hierarchies. It involves storing all paths through a tree,
not just those with a direct parent-child relationship. You
may want to chose this model, over the [Nested Sets model](https://en.wikipedia.org/wiki/Nested_set_model),
should you need referential integrity and to assign nodes to multiple trees.
With CTE you can navigate through hierarchies using a
simple [API](CTE.Adapter.html#functions), such as:
finding the ascendants and descendants of a node, inserting
and deleting nodes, moving entire sub-trees or print them
as a digraph (.dot) file.
### Quick example.
For this example we're using the in-[Memory Adapter](CTE.Adapter.Memory.html#content).
This `Adapter` is useful for prototyping or with data structures
that can easily fit in memory; their persistence being taken care
of by other components. For more involved use cases, CTE integrates
with Ecto using a simple API.
When used from a module, the CTE expects the: `:otp_app` and
`:adapter` attributes, to be defined. The `:otp_app` should point
to an OTP application that might provide additional configuration.
Equally so are the: `:nodes` and the `:paths` attributes. The `:nodes`
attribute, in the case of the [Memory Adapter](CTE.Adapter.Memory.html#content),
is a Map defining your nodes while the: `:paths` attribute, is a list
containing the tree path between the nodes - a list of lists. For example:
defmodule CTM do
use CTE,
otp_app: :ct_empty,
adapter: CTE.Adapter.Memory,
nodes: %{
1 => %{id: 1, author: "Olie", comment: "Is Closure Table better than the Nested Sets?"},
2 => %{id: 2, author: "Rolie", comment: "It depends. Do you need referential integrity?"},
3 => %{id: 3, author: "Olie", comment: "Yeah."}
},
paths: [[1, 1, 0], [1, 2, 1], [1, 3, 2], [2, 2, 0], [2, 3, 1], [3, 3, 0]]
end
When using the `CTE.Adapter.Ecto`, the: `:nodes` attribute,
will be a Schema i.e. `Post`, `TreePath`, etc! In our initial
implementation, the nodes definitions must have at least the
`:id`, as one of their properties. This caveat will be lifted
in a later implementation.
Add the `CTM` module to your main supervision tree:
defmodule CTM.Application do
@moduledoc false
use Application
def start(_type, _args) do
opts = [strategy: :one_for_one, name: CTM.Supervisor]
Supervisor.start_link([CTM], opts)
end
end
Using `iex -S mix`, for quickly experimenting with the CTE API:
- find the descendants of comment #1
```elixir
iex» CTM.descendants(1)
{:ok, [3, 2]}
```
- find the ancestors
```elixir
iex» CTM.ancestors(2)
{:ok, [1]}
iex» CTM.ancestors(3)
{:ok, [1]}
```
- find the ancestors, with information about the node:
```elixir
iex» CTM.ancestors(2, nodes: true)
{:ok,
[
%{
author: "Olie",
comment: "Is Closure Table better than the Nested Sets?",
id: 1
}
]}
```
- move leafs/subtrees around. From being a child of comment #1, to becoming a
child of comment #2, in the following example:
```elixir
iex» CTM.move(3, 2)
:ok
iex» CTM.descendants(2)
{:ok, [3]}
```
Please check the docs, the tests, and the examples folder, for more details.
"""
@type config :: Keyword.t()
@type table :: String.t() | atom
@type nodes :: map() | table
@type paths :: [list()] | table
@type repo :: Ecto.Repo | map()
@type name :: String.t() | atom
@type t :: %__MODULE__{
adapter: any() | nil,
nodes: nodes | nil,
paths: paths | nil,
repo: repo | nil,
name: name | nil
}
defstruct [:nodes, :paths, :adapter, :repo, :name]
defmacro __using__(opts) do
quote bind_quoted: [opts: opts] do
@default_adapter CTE.Adapter.Memory
@default_config [nodes: [], paths: [], adapter: @default_adapter, repo: nil]
@default_dynamic_supervisor opts[:default_dynamic_supervisor] || opts[:name] || __MODULE__
@otp_app Keyword.fetch!(opts, :otp_app)
@adapter Keyword.fetch!(opts, :adapter)
@opts opts
@doc false
def config(), do: parse_config(@opts)
@doc false
def __adapter__ do
{{:repo, _repo}, %{pid: adapter}} = CTE.Registry.lookup(get_dynamic_supervisor())
adapter
end
@doc false
def child_spec(opts) do
%{
id: __MODULE__,
start: {__MODULE__, :start_link, [opts]},
type: :supervisor
}
end
@doc false
def start_link(opts \\ []) do
CTE.Supervisor.start_link(__MODULE__, @otp_app, @adapter, config())
end
@compile {:inline, get_dynamic_supervisor: 0}
def get_dynamic_supervisor() do
Process.get({__MODULE__, :dynamic_supervisor}, @default_dynamic_supervisor)
end
def put_dynamic_supervisor(dynamic) when is_atom(dynamic) or is_pid(dynamic) do
Process.put({__MODULE__, :dynamic_supervisor}, dynamic) || @default_dynamic_supervisor
end
def insert(leaf, ancestor, opts \\ [])
def insert(leaf, ancestor, opts), do: @adapter.insert(__adapter__(), leaf, ancestor, opts)
def tree(leaf, opts \\ [])
def tree(leaf, opts), do: @adapter.tree(__adapter__(), leaf, opts)
def ancestors(descendant, opts \\ [])
def ancestors(descendant, opts), do: @adapter.ancestors(__adapter__(), descendant, opts)
def descendants(ancestor, opts \\ [])
def descendants(ancestor, opts), do: @adapter.descendants(__adapter__(), ancestor, opts)
@doc """
when limit: 1, the default value, then delete only the leafs, else the entire subtree
"""
def delete(leaf, ops \\ [])
def delete(leaf, opts), do: @adapter.delete(__adapter__(), leaf, opts)
def move(leaf, ancestor, opts \\ [])
def move(leaf, ancestor, opts), do: @adapter.move(__adapter__(), leaf, ancestor, opts)
defp parse_config(config), do: CTE.parse_config(@otp_app, __MODULE__, @opts, config)
end
end
@doc false
def parse_config(otp_app, adapter, adapter_config, dynamic_config) do
conf =
Application.get_env(otp_app, adapter, [])
|> Keyword.merge(adapter_config)
|> Keyword.merge(dynamic_config)
|> CTE.interpolate_env_vars()
%CTE{
nodes: Keyword.get(conf, :nodes, []),
paths: Keyword.get(conf, :paths, []),
repo: Keyword.get(conf, :repo, nil),
adapter: Keyword.get(conf, :adapter),
name: Keyword.get(conf, :name)
}
end
@doc false
def interpolate_env_vars(config) do
Enum.map(config, fn
{key, {:system, env_var}} -> {key, System.get_env(env_var)}
{key, value} -> {key, value}
end)
end
end