Packages

A simple state machine compatible with Ecto 1.x

Current section

Files

Jump to
simple_ecto_state_machine lib simple_ecto_state_machine.ex
Raw

lib/simple_ecto_state_machine.ex

defmodule SimpleEctoStateMachine do
import Ecto.Changeset
@moduledoc """
A simple state machine for Ecto Models. It works on the changeset of the
model when the changeset is updated. When a state machine is `use`d inside
and Ecto model, a function of the form `validate_{field}_update(changeset)` is
defined in the model, ready to test the changeset when is updated.
```
defmodule MyModule do
use Ecto.Model
schema "my_module" do
field :status
end
use SimpleEctoStateMachine,
field: :status,
transitions: [
%{from: "status_a". to: ["status_b, "status_c"]},
%{from: "status_b", to: ["status_c"]}
]
def changeset(model, params \\ :empty) do
model
|> cast(params, ["status"], [])
|> validate_status_update # <= Function defined by
# SimpleEctoStateMachine at compile time.
end
end
```
Callbacks can be defined to be called in the case of success or error.
Callbacks can be provided per destination. In case of a successful transition
the callback with the form `{to}_callback` will be called. The error callback
must be named `state_machine_error_callback` and will be called if a
transition with a valid `from` is provided but the new value does not match
any of the valid `to`s, i.e:
```
def success_callback(_changeset) do
IO.puts "Valid transition!"
end
def error_callback(_changeset) do
IO.puts "Invalid transition."
end
use SimpleEctoStateMachine,
field: status
transitions: [
%{
from: "status_a",
to: ["status_b, status_c"],
status_a_callback: &__MODULE__.success_callback/1,
status_b_callback: &__MODULE__.success_callback/1,
error_callback: &__MODULE__.error.callback/1
}
]
```
Make sure to only define one transition per `from`, since repeating `from`s
lead to an error in the state machine. All possible transitions for a `from`
must be defined in a single transition.
"""
defmacro __using__(opts) do
quote bind_quoted: [opts: opts] do
import SimpleEctoStateMachine
field = Keyword.get(opts, :field)
transitions = Keyword.get(opts, :transitions)
def unquote(:"validate_#{field}_update")(changeset, arg) do
field = unquote(field)
transitions = unquote(Macro.escape(transitions))
parsed_transitions = parse_transitions(transitions)
from = Map.from_struct(changeset.model)[field]
to = changeset.changes[field]
if is_nil(to) || to in (parsed_transitions[from]) do
valid_transition(from, to, transitions, changeset, arg)
else
invalid_transition(from, to, field, transitions, changeset, arg)
end
end
def unquote(:"validate_#{field}_update")(changeset) do
field = unquote(field)
transitions = unquote(Macro.escape(transitions))
parsed_transitions = parse_transitions(transitions)
from = Map.from_struct(changeset.model)[field]
to = changeset.changes[field]
if is_nil(to) || to in (parsed_transitions[from]) do
valid_transition(from, to, transitions, changeset)
else
invalid_transition(from, to, field, transitions, changeset)
end
end
end
end
@doc false
def valid_transition(_, nil, _, changeset, _) do
changeset
end
def valid_transition(from, to, transitions, changeset, arg) do
case execute_callback(from, to, transitions, changeset, arg) do
:ok -> changeset
changeset -> changeset
end
end
def valid_transition(_, nil, _, changeset) do
changeset
end
def valid_transition(from, to, transitions, changeset) do
case execute_callback(from, to, transitions, changeset) do
:ok -> changeset
changeset -> changeset
end
end
@doc false
def invalid_transition(from, to, field, transitions, changeset, arg) do
execute_callback(from, "state_machine_error", transitions, changeset, arg)
message = """
Invalid update for #{field}. Wanted to transition from #{from} to #{to} for
the field #{field}.
"""
add_error(changeset, field, message)
end
def invalid_transition(from, to, field, transitions, changeset) do
execute_callback(from, "state_machine_error", transitions, changeset)
message = """
Invalid update for #{field}. Wanted to transition from #{from} to #{to} for
the field #{field}.
"""
add_error(changeset, field, message)
end
@doc false
def get_transition(transitions, from) do
hd(Enum.filter(transitions, fn(t) -> t.from == from end))
end
@doc false
def execute_callback(from, to, transitions, changeset, arg) do
case get_callback(from, to, transitions) do
nil -> changeset
callback -> callback.(changeset, arg)
end
end
@doc false
def execute_callback(from, to, transitions, changeset) do
case get_callback(from, to, transitions) do
nil -> changeset
callback -> callback.(changeset)
end
end
def get_callback(from, to, transitions) do
transitions
|> get_transition(from)
|> Map.get(:"#{String.downcase(to)}_callback")
end
@doc """
Translates the given `transitions` to a map. For example:
```
transitions = [
%{from: "status_a", to: ["status_b"]},
%{from: "status_b", to: ["status_a", "status_c"}]
]
parsed_transitions(transitions)
=> %{
"status_a" => ["status_b"],
"status_b" => ["status_a", "status_c"]
}
```
"""
def parse_transitions(transitions) do
Enum.reduce(transitions, %{}, fn(%{from: from, to: to} = transition, acc) ->
Map.put(acc, from, to)
end)
end
end