Packages

A transition validator for Ecto and EctoEnum.

Current section

Files

Jump to
ecto_transit lib ecto_transit.ex
Raw

lib/ecto_transit.ex

defmodule EctoTransit do
@moduledoc """
Defines transition rules.
When used, the transit expects the `:on` and `:rules` as option. The `:on` should
be a list of avaliable states or an `EctoEnum` module, and `:rules` should be a map with transition rules.
You can use state, list of states or wildcard `:*` in rules.
Optional option `:with` could be used to change generated function name (default `:can_transit?`).
It will be handy if you defines two transitions in one module.
States can be any types, though examples are all atom.
iex> defmodule TODO do
...> @states ~w(created scheduled doing overdued done closed)a
...>
...> @transitions %{
...> :* => :closed,
...> :created => ~w(scheduled doing)a,
...> :scheduled => ~w(doing overdued)a,
...> :doing => ~w(created scheduled done)a
...> }
...>
...> use EctoTransit, on: @states, rules: @transitions
...> end
iex> TODO.can_transit?(nil, :created)
false
iex> TODO.can_transit?(:scheduled, :overdued)
true
iex> TODO.can_transit?(:unknown, :closed)
false
## Use with `EctoEnum`
`EctoTransit` is designed for compatibility with `EctoEnum`, while it works well alone.
Option `:on` can accept an enum module defined by `EctoEnum`,
iex> defmodule Order do
...> import EctoEnum
...> defenum State, ~w(created paid in_deliver done)
...>
...> @transitions %{
...> ~w(created paid in_deliver)a => :done,
...> :created => :paid,
...> :paid => :in_deliver
...> }
...>
...> use EctoTransit, on: State, rules: @transitions, with: :can_continue?
...> end
iex> Order.can_continue?(:created, :paid)
true
iex> Order.can_continue?(:paid, "in_deliver")
true
"""
import Ecto.Changeset
@default_validator :can_transit?
@default_message ~s(cannot transit from %{old} to %{new})
defmacro __using__(opts) do
quote bind_quoted: [
states: opts[:on],
transitions: opts[:rules],
validator: opts[:with] || @default_validator
] do
import EctoTransit
parse_transitions(states, transitions)
|> Enum.each(fn {from, to} ->
def unquote(validator)(unquote(from), unquote(to)), do: true
end)
def unquote(validator)(_, _), do: false
end
end
@doc """
Validate the change on given field follows transition rules.
This function cannot guarantee transition consistency in concurrency updates thus marked as unsafe.
For example, if an change is committed when an check is passed, the follow update
might break transition rules, considering an update action of ecto is not conditional.
Extra lock mechanism is required if you want to ensure consistency.
See `Ecto.Query.lock/2` or `Ecto.Changeset.optimistic_lock/3`.
## Options
* `:message` - the message on failure, defaults to "cannot transit from %{old} to %{new}"
* `:required` - if the change on the field is required, defaults to true
* `:with` - the function to validate transitions, defaults to :can_transit?.
function `fun` can be one of:
* atom - called as `apply(mod, fun, [old, new])`, which `mod` is the module that defines the schama of changeset
* capture or anonymous function - called as `apply(fun, [changeset, {old, new}])`
* {mod, fun, ex_args} - called as `apply(mod, fun, [changeset, {old, new} | ex_args])`
When `required` is true (by default), `old` and `new` could be the same, meaning there's no change on given field.
## Examples
use defualt `:can_transit?/2`:
changeset |> unsafe_validate_transit(:state)
changeset |> unsafe_validate_transit(:state, message: "%{old} -> %{new} is forbidden")
use `required: false` to skip check if no change:
%Post{state: :published}
|> change() ## no change
|> unsafe_validate_transit(:state, required: false) ## skip check
use `Post.can_close?/2`:
%Post{state: :published}
|> change(state: :closed)
|> unsafe_validate_transit(:state, with: :can_close?)
use anonymous function like hook:
recall_message_changeset
|> unsafe_validate_transit :state, with: fn changeset, {from, to} ->
get_field(changeset, :receive_count) == 0 && match?({:sent, :recalled}, {from, to})
end
end
use `Audit.can_change?(changeset, {from, to}, action, metadata)`:
sensitive_changeset
|> unsafe_validate_transit(:state, {Audit, :can_change?, [:update, %{context: nil}]})
use `Ecto.Query.optimistic_lock/3` to ensure consistency:
changeset
|> unsafe_validate_transit(:state)
|> optimistic_lock(:lock_version) # raise if concurrent update happens
"""
@spec unsafe_validate_transit(Ecto.Changeset.t(), atom, keyword) :: Ecto.Changeset.t()
def unsafe_validate_transit(%Ecto.Changeset{} = changeset, field, opts \\ []) do
required = Keyword.get(opts, :required, true)
message = Keyword.get(opts, :message, @default_message)
validator = Keyword.get(opts, :with, @default_validator)
has_change = match?({:ok, _}, fetch_change(changeset, field))
## when has_change is false, get_field fallbacks to read data
## so we can check can_transit?(state, state)
new = changeset |> get_field(field)
old = changeset.data |> Map.get(field)
check = fn ->
if can_transit?(changeset, {old, new}, validator) do
changeset
else
changeset |> add_error(field, message, old: old, new: new)
end
end
case [has_change, required] do
[true, _] -> check.()
[false, true] -> check.()
[false, false] -> changeset
end
end
@doc false
def parse_transitions(states, transitions) do
transitions
|> Enum.map(fn {from, to} ->
for from <- List.wrap(from), to <- List.wrap(to) do
expand_transition(states, from, to)
end
end)
|> List.flatten()
|> Enum.uniq()
end
defp can_transit?(changeset, {from, to}, func) when is_atom(func) do
apply(changeset.data.__struct__, func, [from, to])
end
defp can_transit?(changeset, {from, to}, func) when is_function(func, 2) do
apply(func, [changeset, {from, to}])
end
defp can_transit?(changeset, {from, to}, {mod, func, ex_args}) do
apply(mod, func, [changeset, {from, to} | ex_args])
end
defp expand_transition(states, from, to) when is_list(states),
do: expand_list_transition(states, from, to)
defp expand_transition(states, from, to) when is_atom(states) do
if Code.ensure_compiled?(EctoEnum) and
function_exported?(states, :__enum_map__, 0) and
function_exported?(states, :valid_value?, 1) do
expand_enum_transition(states, from, to)
else
raise "cannot create transition validator on module #{states}, expected an EctoEnum"
end
end
defp expand_list_transition(list, from, :*) do
for to <- list do
expand_list_transition(list, from, to)
end
end
defp expand_list_transition(list, :*, to) do
for from <- list do
expand_list_transition(list, from, to)
end
end
defp expand_list_transition(list, from, to) do
if [from, to] |> Enum.all?(&Kernel.in(&1, list)) do
{from, to}
else
raise "unknown transition state in #{inspect(from)} => #{inspect(to)}"
end
end
defp expand_enum_transition(enum, from, :*) do
for to <- Keyword.keys(enum.__enum_map__) do
expand_enum_transition(enum, from, to)
end
end
defp expand_enum_transition(enum, :*, to) do
for from <- Keyword.keys(enum.__enum_map__) do
expand_enum_transition(enum, from, to)
end
end
defp expand_enum_transition(enum, from, to) when is_atom(from) and is_atom(to) do
case [from, to] |> Enum.map(&enum.valid_value?/1) do
[true, true] ->
[
{from, to},
{enum.__enum_map__[from], to},
{from, enum.__enum_map__[to]},
{enum.__enum_map__[from], enum.__enum_map__[to]}
]
_ ->
raise "unknown transition state in #{inspect(from)} => #{inspect(to)}"
end
end
defp expand_enum_transition(_enum, from, to) do
raise "invalid transition state, expected an Atom, in: #{inspect(from)} => #{inspect(to)}"
end
end