defmodule Ecto.FSM do @moduledoc """ Handle Ecto structures status through FSM Provides macros for defining FSM Defines FSM with `transition/2` and `bypass/2` macros. Caller module is added the following functions: * `fsm() :: Ecto.FSM.specs()` * `docs() :: Ecto.FSM.docs()` Transition functions must return `Ecto.FSM.transition_ret`. Examples: iex> defmodule Elixir.Door do ...> use Ecto.FSM ...> ...> @doc "Close to open" ...> @to [:opened] ...> transition closed({:open, _}, s) do ...> {:next_state, :opened, s} ...> end ...> ...> @doc "Close to close" ...> transition closed({:close, _}, s) do ...> {:next_state, :closed, s} ...> end ...> ...> transition closed({:else, _}, s) do ...> {:next_state, :closed, s} ...> end ...> ...> @doc "Open to open" ...> transition opened({:open, _}, s) do ...> {:next_state, :opened, s} ...> end ...> ...> @doc "Open to close" ...> @to [:closed] ...> transition opened({:close, _}, s) do ...> {:next_state, :closed, s} ...> end ...> ...> transition opened({:else, _}, s) do ...> {:next_state, :opened, s} ...> end ...> ...> @doc "Force the door" ...> bypass force(_, s) do ...> {:next_state, :destroyed, s} ...> end ...> end ...> Door.fsm() %{ {:closed, :close} => {Door, [:closed]}, {:closed, :else} => {Door, [:closed]}, {:closed, :open} => {Door, [:opened]}, {:opened, :close} => {Door, [:closed]}, {:opened, :else} => {Door, [:opened]}, {:opened, :open} => {Door, [:opened]} } ...> Door.docs() %{ {:transition_doc, :closed, :close} => "Close to close", {:transition_doc, :closed, :else} => nil, {:transition_doc, :closed, :open} => "Close to open", {:transition_doc, :opened, :close} => "Open to close", {:transition_doc, :opened, :else} => nil, {:transition_doc, :opened, :open} => "Open to open", {:event_doc, :force} => "Force the door" } """ alias Ecto.Changeset alias Ecto.FSM.Machine alias Ecto.FSM.Schema @type trans :: atom @type params :: term @type handler :: module @type action :: {State.name(), trans} @type result :: {handler, [State.name()]} @type specs :: %{action => result} @type bypasses :: %{trans => handler} @type doc_key :: {:transition_doc, State.name(), trans} | {:event_doc, trans} @type doc :: String.t() @type docs :: %{doc_key => doc} @type info :: {:transition, doc} | {:bypass, doc} @type transition_ret :: {:next_state, State.name(), State.t()} | {:keep_state, State.t()} @type transition :: ({trans, params}, State.t() -> transition_ret) defmacro __using__(_opts) do quote do import Ecto.FSM, only: [transition: 2, bypass: 2] @fsm %{} @bypasses %{} @docs %{} @to nil @__states_names__ [] @before_compile Ecto.FSM end end defmacro __before_compile__(_env) do quote do @doc """ Returns this handler's FSM as `spec()` """ @spec fsm() :: Ecto.FSM.specs() def fsm, do: @fsm @doc """ Returns this handler's FSM bypasses as `spec()` """ @spec event_bypasses() :: Ecto.FSM.bypasses() def event_bypasses, do: @bypasses @doc """ Returns this FSM's doc map """ @spec docs() :: Ecto.FSM.docs() def docs, do: @docs @doc """ Returns this FSM's states names """ @spec states_names() :: [Ecto.State.name()] def states_names, do: @__states_names__ end end @doc """ Define a function of type `transition` describing a state and its transition. The function name is the state name, the transition is the first argument. A state object can be modified and is the second argument. ``` deftrans opened({:close_door, _params}, state) do {:next_state, :closed, state} end ``` """ defmacro transition({state, _meta, [{trans, _params} | _rest]} = signature, body_block) do do_block = Keyword.get(body_block, :do) transition = case trans do {:_, _, _} -> :_ t when is_atom(t) -> t end next_states = do_block |> find_nextstates(state) |> Enum.uniq() quote do state_name = unquote(state) trans = unquote(transition) next_states = @to || unquote(next_states) @fsm Map.put(@fsm, {state_name, trans}, {__MODULE__, next_states}) @__states_names__ [state_name | @__states_names__] |> Enum.uniq() doc = __MODULE__ |> Module.get_attribute(:doc) |> case do nil -> nil {_line, doc} -> doc doc when is_binary(doc) -> doc end @docs Map.put(@docs, {:transition_doc, state_name, trans}, doc) def unquote(signature), do: unquote(do_block) @to nil end end @doc """ Define a function of type `bypass`, ie which can be applied on any state """ defmacro bypass({trans, _meta, _args} = signature, body_block) do quote do @bypasses Map.put(@bypasses, unquote(trans), __MODULE__) doc = __MODULE__ |> Module.get_attribute(:doc) |> case do nil -> nil {_line, doc} -> doc doc when is_binary(doc) -> doc end @docs Map.put(@docs, {:event_doc, unquote(trans)}, doc) def unquote(signature), do: unquote(body_block[:do]) end end @doc """ Executes action on a changeset with associated FSM """ @spec action(Changeset.t(), trans, params) :: Changeset.t() def action(%Changeset{} = cs, action, params) do if Machine.action_available?(cs, action) do do_action(cs, action, params) else Changeset.add_error( cs, Schema.State.field(cs), "action not available in this state: #{action}" ) end end ### ### Priv ### defp do_action(%Changeset{} = cs, action, params) do case Machine.event(cs, {action, params}) do {:ok, cs} -> cs {:error, :illegal_action} -> Changeset.put_change(cs, Schema.State.field(cs), "illegal action: #{action}") {:error, err} -> Changeset.put_change(cs, Schema.State.field(cs), inspect(err)) end end defp find_nextstates({:keep_state, _state_ast}, state_name), do: [state_name] defp find_nextstates({:{}, _, [:next_state, state_name, _state_ast]}, _) when is_atom(state_name), do: [state_name] defp find_nextstates({_, _, asts}, state_name), do: find_nextstates(asts, state_name) defp find_nextstates({_, asts}, state_name), do: find_nextstates(asts, state_name) defp find_nextstates(asts, state_name) when is_list(asts), do: Enum.flat_map(asts, fn ast -> find_nextstates(ast, state_name) end) defp find_nextstates(_, _), do: [] end