Current section
Files
Jump to
Current section
Files
lib/ex_state/execution.ex
defmodule ExState.Execution do
@moduledoc """
`ExState.Execution` executes state transitions with a state chart.
"""
alias ExState.Result
alias ExState.Definition.Chart
alias ExState.Definition.State
alias ExState.Definition.Step
alias ExState.Definition.Transition
@type t :: %__MODULE__{
chart: Chart.t(),
state: State.t(),
actions: [atom()],
history: [State.t()],
transitions: [Transition.t()],
callback_mod: module(),
subject: any(),
meta: map()
}
defstruct chart: %Chart{},
state: nil,
actions: [],
history: [],
transitions: [],
callback_mod: nil,
subject: nil,
meta: %{}
@doc """
Creates a new workflow execution from the initial state.
"""
@spec new(module(), struct()) :: t()
def new(workflow, subject) do
new(workflow.definition, workflow, subject)
end
@spec new(Chart.t(), module(), struct()) :: t()
def new(chart, callback_mod, subject) do
%__MODULE__{chart: chart, callback_mod: callback_mod, subject: subject}
|> enter_state(chart.initial_state)
end
@doc """
Continues a workflow execution from the specified state.
"""
@spec continue(module(), struct(), String.t()) :: t()
def continue(workflow, subject, state_name) do
continue(workflow.definition, workflow, subject, state_name)
end
@spec continue(Chart.t(), module(), struct(), String.t()) :: t()
def continue(chart, callback_mod, subject, state_name) when is_bitstring(state_name) do
%__MODULE__{chart: chart, callback_mod: callback_mod, subject: subject}
|> enter_state(state_name, entry_actions: false)
end
@doc """
Continues a workflow execution with the completed steps.
Use in conjunction with `continue` to resume execution.
"""
@spec with_completed(t(), String.t(), String.t(), any()) :: t()
def with_completed(execution, state_name, step_name, decision \\ nil)
def with_completed(
%__MODULE__{state: %State{name: state_name}} = execution,
state_name,
step_name,
decision
) do
put_state(execution, State.put_completed_step(execution.state, step_name, decision))
end
def with_completed(execution, state_name, step_name, decision) do
case Enum.find(execution.history, fn state -> state.name == state_name end) do
nil ->
put_history(
execution,
State.put_completed_step(get_state(execution, state_name), step_name, decision)
)
state ->
put_history(
execution,
Enum.map(execution.history, fn
%State{name: ^state_name} -> State.put_completed_step(state, step_name, decision)
state -> state
end)
)
end
end
@spec with_meta(t(), any(), any()) :: t()
def with_meta(execution, key, value) do
%__MODULE__{execution | meta: Map.put(execution.meta, key, value)}
end
defp enter_state(execution, name, opts \\ [])
defp enter_state(execution, name, opts) when is_bitstring(name) do
enter_state(execution, get_state(execution, name), opts)
end
defp enter_state(execution, %State{} = state, opts) do
execution
|> put_history()
|> put_state(state)
|> filter_steps()
|> put_actions(opts)
|> enter_initial_state()
|> handle_final()
|> handle_null()
|> handle_no_steps()
end
defp enter_initial_state(%__MODULE__{state: %State{initial_state: nil}} = execution) do
execution
end
defp enter_initial_state(%__MODULE__{state: %State{initial_state: initial_state}} = execution) do
enter_state(execution, get_state(execution, initial_state), transition_actions: false)
end
defp handle_final(%__MODULE__{state: %State{type: :final}} = execution) do
transition_maybe(execution, :__final__)
end
defp handle_final(execution) do
execution
end
defp handle_null(execution) do
transition_maybe(execution, :_)
end
defp handle_no_steps(%__MODULE__{state: %State{type: :atomic, steps: []}} = execution) do
transition_maybe(execution, :__no_steps__)
end
defp handle_no_steps(execution) do
execution
end
defp filter_steps(%__MODULE__{state: state} = execution) do
put_state(execution, State.filter_steps(state, fn step -> use_step?(execution, step) end))
end
defp put_history(%__MODULE__{state: nil} = execution), do: execution
defp put_history(execution) do
put_history(execution, execution.state)
end
defp put_history(execution, %State{} = state) do
put_history(execution, [state | execution.history])
end
defp put_history(execution, history) when is_list(history) do
%__MODULE__{execution | history: history}
end
def get_state(execution, name) do
Chart.state(execution.chart, name)
end
def put_state(execution, state) do
%__MODULE__{execution | state: state}
end
def put_transition(execution, transition) do
%__MODULE__{execution | transitions: [transition | execution.transitions]}
end
@doc """
Completes a step and transitions the execution with `{:completed, step_id}` event.
"""
@spec complete(t(), atom()) :: {:ok, t()} | {:error, String.t(), t()}
def complete(execution, step_id) do
case State.complete_step(execution.state, step_id) do
{:ok, state} ->
case do_transition(put_state(execution, state), {:completed, step_id}) do
{:ok, execution} ->
{:ok, execution}
{:error, :no_transition, _reason, execution} ->
{:ok, execution}
{:error, _kind, reason, execution} ->
{:error, reason, execution}
end
{:error, next_steps, state} ->
{:error, step_error(next_steps), put_state(execution, state)}
end
end
def complete!(execution, step_id) do
complete(execution, step_id) |> Result.get()
end
@doc """
Completes a decision and transitions the execution with `{:decision, step_id, decision}` event.
"""
@spec decision(t(), atom(), atom()) :: {:ok, t()} | {:error, String.t(), t()}
def decision(execution, step_id, decision) do
case State.complete_step(execution.state, step_id, decision) do
{:ok, state} ->
case do_transition(put_state(execution, state), {:decision, step_id, decision}) do
{:ok, execution} ->
{:ok, execution}
{:error, _kind, reason, execution} ->
{:error, reason, execution}
end
{:error, next_steps, state} ->
{:error, step_error(next_steps), put_state(execution, state)}
end
end
def decision!(execution, step_id, decision) do
decision(execution, step_id, decision) |> Result.get()
end
defp step_error([]), do: "no next step"
defp step_error([next_step]), do: "next step is: #{next_step.name}"
defp step_error(next_steps) when is_list(next_steps) do
"next steps are: #{Enum.map(next_steps, fn step -> step.name end) |> Enum.join(", ")}"
end
@doc """
Transitions execution with the event and returns a result tuple.
"""
@spec transition(t(), Transition.event()) :: {:ok, t()} | {:error, String.t(), t()}
def transition(execution, event) do
case do_transition(execution, event) do
{:ok, execution} ->
{:ok, execution}
{:error, _kind, reason, execution} ->
{:error, reason, execution}
end
end
def transition!(execution, event) do
transition(execution, event) |> Result.get()
end
@doc """
Transitions execution with the event and returns updated or unchanged execution.
"""
def transition_maybe(execution, event) do
case do_transition(execution, event) do
{:ok, execution} ->
execution
{:error, _kind, _reason, execution} ->
execution
end
end
@spec do_transition(t(), Transition.event()) :: {:ok, t()} | {:error, atom(), any(), t()}
defp do_transition(%__MODULE__{state: %State{name: current_state}} = execution, event) do
case State.transition(execution.state, event) do
nil ->
case Chart.parent(execution.chart, execution.state) do
nil ->
no_transition(execution, event)
parent ->
case do_transition(put_state(execution, parent), event) do
{:ok, execution} ->
{:ok, execution}
{:error, kind, reason, _} ->
{:error, kind, reason, execution}
end
end
%Transition{target: ^current_state, reset: false} = transition ->
next =
execution
|> add_actions(transition.actions)
{:ok, next}
%Transition{target: target} = transition when is_list(target) ->
Enum.reduce_while(target, no_transition(execution, event), fn target, e ->
case use_target(execution, transition, target) do
{:ok, next} -> {:halt, {:ok, next}}
{:error, _code, _reason, _execution} -> {:cont, e}
end
end)
%Transition{target: target} = transition ->
use_target(execution, transition, target)
end
end
defp no_transition(execution, event) do
{:error, :no_transition,
"no transition from #{execution.state.name} for event #{inspect(event)}", execution}
end
defp use_target(execution, transition, target) do
case get_state(execution, target) do
nil ->
{:error, :no_state, "no state found for transition to #{target}", execution}
state ->
case guard_transition(execution, state) do
:ok ->
next =
execution
|> put_transition(transition)
|> enter_state(state)
{:ok, next}
{:error, reason} ->
{:error, :guard_transition, reason, execution}
end
end
end
defp guard_transition(execution, state) do
if function_exported?(execution.callback_mod, :guard_transition, 3) do
execution.callback_mod.guard_transition(
execution.subject,
State.id(execution.state),
State.id(state)
)
else
:ok
end
end
def will_transition?(execution, event) do
transition_maybe(execution, event).state != execution.state
end
def complete?(execution), do: State.final?(execution.state)
@doc """
Returns serializable data representing the execution.
"""
def dump(execution) do
%{
name: execution.chart.name,
state: execution.state.name,
complete?: complete?(execution),
steps: dump_steps(execution),
participants: dump_participants(execution),
subject: dump_subject(execution)
}
end
defp dump_subject(execution) do
case execution.chart.subject do
nil -> nil
{key, _} -> {key, execution.subject}
key -> key
end
end
defp dump_participants(execution) do
Enum.map(execution.chart.participants, fn name ->
dump_participant(name)
end)
end
defp dump_participant(nil), do: nil
defp dump_participant(name), do: Atom.to_string(name)
defp dump_steps(execution) do
execution
|> merge_states()
|> Enum.flat_map(fn state ->
state.steps
|> Enum.filter(fn step -> use_step?(execution, step) end)
|> Enum.map(fn step ->
%{
state: state.name,
order: step.order,
name: step.name,
complete?: step.complete?,
decision: step.decision,
participant: dump_participant(step.participant)
}
end)
end)
end
defp merge_states(execution) do
Enum.map(execution.chart.states, fn {_, state} ->
case execution.state.name == state.name do
true ->
execution.state
false ->
case Enum.find(execution.history, fn s -> s.name == state.name end) do
nil -> state
history_state -> history_state
end
end
end)
end
defp use_step?(execution, step) do
if function_exported?(execution.callback_mod, :use_step?, 2) do
execution.callback_mod.use_step?(execution.subject, Step.id(step))
else
true
end
end
defp put_actions(execution, opts) do
execution =
if Keyword.get(opts, :exit_actions, true) do
put_exit_actions(execution)
else
execution
end
execution =
if Keyword.get(opts, :transition_actions, true) do
put_transition_actions(execution)
else
execution
end
execution =
if Keyword.get(opts, :entry_actions, true) do
put_entry_actions(execution)
else
execution
end
execution
end
@doc """
Executes any queued actions on the execution.
"""
@spec execute_actions(t()) :: {:ok, t(), map()} | {:error, t(), any()}
def execute_actions(execution) do
execution.actions
|> Enum.reverse()
|> Enum.reduce({:ok, execution, %{}}, fn
_next, {:error, _reason} = e ->
e
next, {:ok, execution, acc} ->
case execute_action(execution, next) do
{:ok, execution, result} ->
{:ok, execution, Map.put(acc, next, result)}
{:error, _reason} = e ->
e
end
end)
|> case do
{:ok, execution, results} ->
{:ok, reset_actions(execution), results}
{:error, reason} ->
{:error, execution, reason}
end
end
@doc """
Executes the provided action name through the callback module.
"""
@spec execute_action(t(), atom()) :: {:ok, t(), any()} | {:error, any()}
def execute_action(execution, action) do
if function_exported?(execution.callback_mod, action, 1) do
case apply(execution.callback_mod, action, [execution.subject]) do
:ok ->
{:ok, execution, nil}
{:ok, result} ->
{:ok, execution, result}
{:updated, subject} ->
{:ok, %__MODULE__{execution | subject: subject}, subject}
e ->
e
end
else
{:error, "no function defined for action #{action}"}
end
end
defp reset_actions(execution) do
%__MODULE__{execution | actions: []}
end
defp add_actions(execution, actions) do
%__MODULE__{execution | actions: actions ++ execution.actions}
end
defp put_exit_actions(%__MODULE__{state: current, history: [last | _rest]} = execution) do
cond do
State.child?(last, current) ->
execution
!State.sibling?(last, current) ->
execution
|> add_actions(State.actions(last, :exit))
|> add_actions(State.actions(Chart.parent(execution.chart, last), :exit))
true ->
add_actions(execution, State.actions(last, :exit))
end
end
defp put_exit_actions(execution), do: execution
defp put_transition_actions(%__MODULE__{transitions: [last | _rest]} = execution) do
add_actions(execution, last.actions)
end
defp put_transition_actions(execution), do: execution
defp put_entry_actions(%__MODULE__{state: current} = execution) do
add_actions(execution, State.actions(current, :entry))
end
end