defmodule StateMachine.Ecto do
@moduledoc """
This addition makes StateMachine fully compatible with Ecto.
State setter and getter are abstracted in order to provide a way to update a state
in the middle of transition for a various types of models. With Ecto, we call `change() |> Repo.update`.
We also wrap every event in transaction, which is rolled back if transition fails to finish.
This unlocks a lot of beautiful effects. For example, you can enqueue some tasks into db-powered queue in callbacks,
and if transition failes, those tasks will naturally disappear.
### Usage
To use Ecto, simply pass `repo` param to `defmachine`, you can optionally pass a name of the `Ecto.Type`
implementation, that will be generated automatically under state machine namespace:
defmodule EctoMachine do
use StateMachine
defmachine field: :state, repo: TestApp.Repo, ecto_type: CustomMod do
state :resting
state :working
# ...
end
end
In your schema you can refer to state type as `EctoMachine.CustomMod`, with `ecto_type` omitted
it would generate `EctoMachine.StateType`. This custom type is needed to transparently use atoms as states.
"""
@doc """
This macro defines an Ecto.Type implementation inside of StateMachine namespace.
The default name is `StateType`, but you can supply any module name.
The purpose of this is to be able to cast string into atom and back safely,
validating it against StateMachine defition.
"""
defmacro define_ecto_type(kind) do
quote do
variants = Module.get_attribute(__MODULE__, :"#{unquote(kind)}_names")
name = Module.get_attribute(__MODULE__, :"#{unquote(kind)}_type")
unless variants do
raise CompileError, [
file: __ENV__.file,
line: __ENV__.line,
description: "Ecto type should be declared inside of state machine definition"
]
end
defmodule Module.concat(__MODULE__, name) do
@variants variants
StateMachine.Ecto.define_enum(@variants)
end
end
end
defmacro define_enum(variants) do
quote do
@behaviour Ecto.Type
def type, do: :string
def cast(value) do
if s = Enum.find(unquote(variants), &to_string(&1) == to_string(value)) do
{:ok, s}
else
:error
end
end
def load(value) do
{:ok, String.to_atom(value)}
end
def dump(value) when value in unquote(variants) do
{:ok, to_string(value)}
end
def dump(_), do: :error
def equal?(s1, s2), do: to_string(s1) == to_string(s2)
def embed_as(_), do: :self
end
end
@behaviour StateMachine.State
@impl true
def get(ctx) do
Map.get(ctx.model, ctx.definition.field)
end
@impl true
def set(ctx, state) do
Ecto.Changeset.change(ctx.model, [{ctx.definition.field, state}])
|> ctx.definition.misc[:repo].update()
|> case do
{:ok, model} ->
%{ctx | model: model}
{:error, e} ->
%{ctx | status: :failed, message: {:set_state, e}}
end
end
end