ash_state_machine/lib/ash_state_machine.ex

247 lines
7.6 KiB
Elixir
Raw Normal View History

defmodule AshStateMachine do
defmodule Transition do
2023-04-22 05:43:36 +12:00
@moduledoc """
The configuration for an transition.
2023-04-22 05:43:36 +12:00
"""
2023-04-22 07:25:39 +12:00
@type t :: %__MODULE__{
action: atom,
from: [atom],
2023-08-05 09:47:42 +12:00
to: [atom],
__identifier__: any
2023-04-22 07:25:39 +12:00
}
2023-08-05 09:47:42 +12:00
defstruct [:action, :from, :to, :__identifier__]
2023-04-22 05:43:36 +12:00
end
require Logger
@transition %Spark.Dsl.Entity{
name: :transition,
target: Transition,
2023-04-22 05:43:36 +12:00
args: [:action],
2023-08-05 09:47:42 +12:00
identifier: {:auto, :unique_integer},
2023-04-22 05:43:36 +12:00
schema: [
action: [
type: :atom,
required: true,
2023-04-23 10:28:19 +12:00
doc:
"The corresponding action that is invoked for the transition. Use `:*` to allow any update action to perform this transition."
2023-04-22 05:43:36 +12:00
],
from: [
type: {:or, [{:list, :atom}, :atom]},
2023-04-23 12:12:11 +12:00
required: true,
2023-04-22 05:43:36 +12:00
doc:
2023-08-05 09:47:42 +12:00
"The states in which this action may be called. If not specified, then any state is accepted. Use `:*` to refer to all states."
2023-04-22 05:43:36 +12:00
],
to: [
type: {:or, [{:list, :atom}, :atom]},
2023-04-23 12:12:11 +12:00
required: true,
2023-04-22 05:43:36 +12:00
doc:
2023-08-05 09:47:42 +12:00
"The states that this action may move to. If not specified, then any state is accepted. Use `:*` to refer to all states."
2023-04-22 05:43:36 +12:00
]
]
}
@transitions %Spark.Dsl.Section{
name: :transitions,
describe: """
# Wildcards
Use `:*` to represent "any action" when used in place of an action, or "any state" when used in place of a state.
2023-09-16 04:11:50 +12:00
For example:
```elixir
transition :*, from: :*, to: :*
```
The full list of states is derived at compile time from the transitions.
Use the `extra_states` to express that certain types should be included
in that list even though no transitions go to/from that state explicitly.
This is necessary for cases where there are states that use `:*` and no
transition explicitly leads to that transition.
""",
2023-04-22 05:43:36 +12:00
entities: [
@transition
2023-04-22 05:43:36 +12:00
]
}
@state_machine %Spark.Dsl.Section{
name: :state_machine,
2023-04-22 05:43:36 +12:00
schema: [
2023-04-22 07:25:39 +12:00
deprecated_states: [
type: {:list, :atom},
default: [],
2023-04-22 07:25:39 +12:00
doc: """
A list of states that have been deprecated but are still valid. These will still be in the possible list of states, but `:*` will not include them.
"""
],
extra_states: [
type: {:list, :atom},
default: [],
doc: """
A list of states that may be used by transitions to/from `:*`. See the docs on wildcards for more.
2023-04-22 07:25:39 +12:00
"""
],
2023-04-22 05:43:36 +12:00
state_attribute: [
type: :atom,
doc: "The attribute to store the state in.",
default: :state
],
initial_states: [
2023-04-23 12:39:29 +12:00
type: {:list, :atom},
required: true,
doc: "The allowed starting states of this state machine."
2023-04-22 07:25:39 +12:00
],
default_initial_state: [
type: :atom,
doc: "The default initial state"
2023-04-22 05:43:36 +12:00
]
],
sections: [
@transitions
2023-04-22 05:43:36 +12:00
]
}
2023-04-23 18:46:09 +12:00
@sections [@state_machine]
@moduledoc """
Provides tools for defining and working with resource-backed state machines.
2023-04-23 18:46:09 +12:00
"""
2023-04-22 07:25:39 +12:00
use Spark.Dsl.Extension,
2023-04-23 18:46:09 +12:00
sections: @sections,
2023-04-22 07:25:39 +12:00
transformers: [
AshStateMachine.Transformers.FillInTransitionDefaults,
AshStateMachine.Transformers.AddState,
AshStateMachine.Transformers.EnsureStateSelected
2023-04-22 07:25:39 +12:00
],
verifiers: [
AshStateMachine.Verifiers.VerifyTransitionActions,
AshStateMachine.Verifiers.VerifyDefaultInitialState
2023-04-22 07:25:39 +12:00
],
imports: [
AshStateMachine.BuiltinChanges
2023-04-22 07:25:39 +12:00
]
@doc """
A utility to transition the state of a changeset, honoring the rules of the resource.
"""
2023-04-22 07:25:39 +12:00
def transition_state(%{action_type: :update} = changeset, target) do
attribute = AshStateMachine.Info.state_machine_state_attribute!(changeset.resource)
2023-04-22 07:25:39 +12:00
old_state = Map.get(changeset.data, attribute)
if target in AshStateMachine.Info.state_machine_all_states(changeset.resource) do
find_and_perform_transition(changeset, old_state, attribute, target)
else
no_such_state(changeset, target)
2023-04-22 07:25:39 +12:00
end
end
def transition_state(%{action_type: :create} = changeset, target) do
attribute = AshStateMachine.Info.state_machine_state_attribute!(changeset.resource)
if target in AshStateMachine.Info.state_machine_initial_states!(changeset.resource) do
Ash.Changeset.force_change_attribute(changeset, attribute, target)
else
changeset
|> Ash.Changeset.set_context(%{state_machine: %{attempted_change: target}})
|> Ash.Changeset.add_error(
AshStateMachine.Errors.InvalidInitialState.exception(
target: target,
action: changeset.action.name
)
)
end
end
def transition_state(other, _target) do
Ash.Changeset.add_error(other, "Can't transition states on destroy actions")
end
defp find_and_perform_transition(changeset, old_state, attribute, target) do
changeset.resource
|> AshStateMachine.Info.state_machine_transitions(changeset.action.name)
|> Enum.find(fn transition ->
old_state in List.wrap(transition.from) and target in List.wrap(transition.to)
end)
|> case do
nil ->
changeset
|> Ash.Changeset.set_context(%{state_machine: %{attempted_change: target}})
|> Ash.Changeset.add_error(
AshStateMachine.Errors.NoMatchingTransition.exception(
old_state: old_state,
target: target,
action: changeset.action.name
)
)
_transition ->
Ash.Changeset.force_change_attribute(changeset, attribute, target)
end
end
@doc false
def no_such_state(changeset, target, old_state \\ nil) do
Logger.error("""
Attempted to transition to an unknown state.
This usually means that one of the following is true:
* You have a missing transition definition in your state machine
To remediate this, add a transition.
* You are using `:*` to include a state that appears nowhere in the state machine definition
To remediate this, add the `extra_states` option and include the state #{inspect(target)}
""")
changeset
|> Ash.Changeset.set_context(%{state_machine: %{attempted_change: target}})
|> Ash.Changeset.add_error(
AshStateMachine.Errors.NoMatchingTransition.exception(
old_state: old_state,
target: target,
action: changeset.action.name
)
)
end
@doc """
A reusable helper which returns all possible next states for a record
(regardless of action).
"""
@spec possible_next_states(Ash.Resource.record()) :: [atom]
def possible_next_states(%resource{} = record) do
state_attribute = AshStateMachine.Info.state_machine_state_attribute!(resource)
current_state = Map.fetch!(record, state_attribute)
resource
|> AshStateMachine.Info.state_machine_transitions()
|> Enum.map(&%{from: List.wrap(&1.from), to: List.wrap(&1.to)})
|> Enum.filter(&(current_state in &1.from or :* in &1.from))
|> Enum.flat_map(& &1.to)
|> Enum.reject(&(&1 == :*))
|> Enum.uniq()
end
@doc """
A reusable helper which returns all possible next states for a record given a
specific action.
"""
@spec possible_next_states(Ash.Resource.record(), atom) :: [atom]
def possible_next_states(%resource{} = record, action_name) when is_atom(action_name) do
state_attribute = AshStateMachine.Info.state_machine_state_attribute!(resource)
current_state = Map.fetch!(record, state_attribute)
resource
|> AshStateMachine.Info.state_machine_transitions(action_name)
|> Enum.map(&%{from: List.wrap(&1.from), to: List.wrap(&1.to)})
|> Enum.filter(&(current_state in &1.from or :* in &1.from))
|> Enum.flat_map(& &1.to)
|> Enum.reject(&(&1 == :*))
|> Enum.uniq()
end
2023-04-22 05:43:36 +12:00
end