Packages

A Finite-state machine implementation in Elixir, with opt-in Ecto friendliness.

Current section

Files

Jump to
fsmx lib fsmx.ex
Raw

lib/fsmx.ex

defmodule Fsmx do
@moduledoc """
"""
alias Fsmx.Fsm
@type state_t :: binary() | atom()
@type opts_t :: [state_field: atom()]
@spec transition(struct(), state_t(), opts_t()) :: {:ok, struct} | {:error, any}
def transition(%{} = struct, new_state, opts \\ []) do
opts = Keyword.put_new(opts, :state_field, Fsm.default_state_field())
state_field = Keyword.get(opts, :state_field)
with {:ok, struct} <- before_transition(struct, new_state, state_field) do
{:ok, struct |> Map.put(state_field, new_state)}
end
end
if Code.ensure_loaded?(Ecto) do
@spec transition_changeset(struct(), state_t(), map, opts_t()) :: Ecto.Changeset.t()
def transition_changeset(%mod{} = schema, new_state, params \\ %{}, opts \\ []) do
opts = Keyword.put_new(opts, :state_field, Fsm.default_state_field())
state_field = Keyword.get(opts, :state_field)
state = schema |> Map.fetch!(state_field)
fsm = mod.__fsmx__(state_field)
with {:ok, schema} <- before_transition(schema, new_state, state_field) do
schema
|> Ecto.Changeset.change()
|> Ecto.Changeset.put_change(state_field, new_state)
|> then(fn changeset ->
if function_exported?(fsm, :transition_changeset, 4) do
fsm.transition_changeset(changeset, state, new_state, params)
else
fsm.transition_changeset(changeset, state, new_state, params, state_field)
end
end)
else
{:error, msg} ->
schema
|> Ecto.Changeset.change()
|> Ecto.Changeset.add_error(state_field, "transition_changeset failed: #{msg}")
end
end
@spec transition_multi(Ecto.Multi.t(), struct(), any, state_t, map, opts_t()) ::
Ecto.Multi.t()
def transition_multi(multi, %mod{} = schema, id, new_state, params \\ %{}, opts \\ []) do
opts = Keyword.put_new(opts, :state_field, Fsm.default_state_field())
state_field = Keyword.get(opts, :state_field)
state = schema |> Map.fetch!(state_field)
fsm = mod.__fsmx__(state_field)
changeset = transition_changeset(schema, new_state, params, state_field: state_field)
multi
|> Ecto.Multi.update(id, changeset)
|> Ecto.Multi.run("#{id}-callback", fn _repo, changes ->
if function_exported?(fsm, :after_transition_multi, 3) do
fsm.after_transition_multi(Map.fetch!(changes, id), state, new_state)
else
fsm.after_transition_multi(Map.fetch!(changes, id), state, new_state, state_field)
end
end)
end
end
defp before_transition(%mod{} = struct, new_state, state_field) do
fsm = mod.__fsmx__(state_field)
state = struct |> Map.fetch!(state_field)
transitions = fsm.__fsmx__(state_field, :transitions)
with :ok <- validate_transition(state, new_state, transitions, state_field) do
if function_exported?(fsm, :before_transition, 3) do
fsm.before_transition(struct, state, new_state)
else
fsm.before_transition(struct, state, new_state, state_field)
end
end
end
defp validate_transition(state, new_state, transitions, state_field) do
transitions
|> from_source_or_fallback(state)
|> is_or_contains?(new_state)
|> if do
:ok
else
if state_field == Fsm.default_state_field() do
{:error, "invalid transition from #{state} to #{new_state}"}
else
{:error, "invalid transition from #{state} to #{new_state} for field #{state_field}"}
end
end
end
defp from_source_or_fallback(transition, state) do
Map.take(transition, [state, :*])
|> Enum.flat_map(fn
{_, valid_states} when is_list(valid_states) -> valid_states
{_, valid_state} -> [valid_state]
end)
end
defp is_or_contains?(:*, _), do: true
defp is_or_contains?(state, state), do: true
defp is_or_contains?(states, state) when is_list(states),
do: Enum.member?(states, state) || Enum.member?(states, :*)
defp is_or_contains?(_, _), do: false
end