improvement: Add possible_next_states helper. (#9)

* improvement: Add `possible_next_states` helper.

* chore: add `mix spark.cheat_sheets`.
This commit is contained in:
James Harton 2023-09-15 15:15:29 +12:00 committed by GitHub
parent fa109180e4
commit 5cba78616c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 205 additions and 20 deletions

View file

@ -1,6 +1,7 @@
spark_locals_without_parens = [
default_initial_state: 1,
deprecated_states: 1,
extra_states: 1,
from: 1,
initial_states: 1,
state_attribute: 1,

View file

@ -0,0 +1,148 @@
# DSL: AshStateMachine
Functions for working with AshStateMachine.
<!--- ash-hq-hide-start --> <!--- -->
## DSL Documentation
### Index
* state_machine
* transitions
* transition
### Docs
## state_machine
* [transitions](#module-transitions)
* transition
---
* `:deprecated_states` (list of `t:atom/0`) - A list of states that have been deprecated.
The list of states is derived from the transitions normally.
Use this option to express that certain types should still
be included in the derived state list even though no transitions
go to/from that state anymore. `:*` transitions will not include
these states. The default value is `[]`.
* `:extra_states` (list of `t:atom/0`) - A list of states that may be used by transitions to/from `:*`
The list of states is derived from the transitions normally.
Use this option to express that certain types should still
be included even though no transitions go to/from that state anymore.
`:*` transitions will include these states. The default value is `[]`.
* `:state_attribute` (`t:atom/0`) - The attribute to store the state in. The default value is `:state`.
* `:initial_states` (list of `t:atom/0`) - Required. The allowed starting states of this state machine.
* `:default_initial_state` (`t:atom/0`) - The default initial state
### transitions
* [transition](#module-transition)
---
#### transition
* `:action` (`t:atom/0`) - The corresponding action that is invoked for the transition. Use `:*` to allow any update action to perform this transition.
* `:from` - Required. The states in which this action may be called. If not specified, then any state is accepted. Use `:*` to refer to all states.
* `:to` - Required. The states that this action may move to. If not specified, then any state is accepted. Use `:*` to refer to all states.
<!--- ash-hq-hide-stop --> <!--- -->
## state_machine
* [transitions](#state_machine-transitions)
* transition
### Options
| Name | Type | Default | Docs |
| --- | --- | --- | --- |
| `deprecated_states` | `list(atom)` | [] | A list of states that have been deprecated. The list of states is derived from the transitions normally. Use this option to express that certain types should still be included in the derived state list even though no transitions go to/from that state anymore. `:*` transitions will not include these states. |
| `extra_states` | `list(atom)` | [] | A list of states that may be used by transitions to/from `:*` The list of states is derived from the transitions normally. Use this option to express that certain types should still be included even though no transitions go to/from that state anymore. `:*` transitions will include these states. |
| `state_attribute` | `atom` | :state | The attribute to store the state in. |
| `initial_states`* | `list(atom)` | | The allowed starting states of this state machine. |
| `default_initial_state` | `atom` | | The default initial state |
## state_machine.transitions
* [transition](#state_machine-transitions-transition)
## state_machine.transitions.transition
### Arguments
| Name | Type | Default | Docs |
| --- | --- | --- | --- |
| `action` | `atom` | | The corresponding action that is invoked for the transition. Use `:*` to allow any update action to perform this transition. |
### Options
| Name | Type | Default | Docs |
| --- | --- | --- | --- |
| `from`* | `list(atom) \| atom` | | The states in which this action may be called. If not specified, then any state is accepted. Use `:*` to refer to all states. |
| `to`* | `list(atom) \| atom` | | The states that this action may move to. If not specified, then any state is accepted. Use `:*` to refer to all states. |
### Introspection
Target: `AshStateMachine.Transition`

View file

@ -199,4 +199,40 @@ defmodule AshStateMachine do
def transition_state(other, _target) do
Ash.Changeset.add_error(other, "Can't transition states on destroy actions")
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
end

View file

@ -6,23 +6,8 @@ defmodule AshStateMachine.BuiltinChanges.NextState do
use Ash.Resource.Change
def change(changeset, _opts, _) do
attribute = AshStateMachine.Info.state_machine_state_attribute!(changeset.resource)
current_state = Map.get(changeset.data, attribute)
changeset.resource
|> AshStateMachine.Info.state_machine_transitions(changeset.action.name)
|> Enum.filter(fn
%{from: from} when is_list(from) -> current_state in from || :* in from
%{from: :*} -> true
%{from: from} -> current_state == from
end)
|> Enum.flat_map(fn
%{to: to} when is_list(to) -> to
%{to: to} -> [to]
end)
|> Enum.uniq()
|> Enum.reject(&(&1 == :*))
changeset.data
|> AshStateMachine.possible_next_states(changeset.action.name)
|> case do
[to] ->
AshStateMachine.transition_state(changeset, to)

View file

@ -152,7 +152,8 @@ defmodule AshStateMachine.MixProject do
sobelow: "sobelow --skip",
credo: "credo --strict",
docs: ["docs", "ash.replace_doc_links"],
"spark.formatter": "spark.formatter --extensions AshStateMachine"
"spark.formatter": "spark.formatter --extensions AshStateMachine",
"spark.cheat_sheets": "spark.cheat_sheets --extensions AshStateMachine"
]
end
end

View file

@ -30,8 +30,8 @@
"parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"},
"picosat_elixir": {:hex, :picosat_elixir, "0.2.3", "bf326d0f179fbb3b706bb2c15fbc367dacfa2517157d090fdfc32edae004c597", [:make, :mix], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "f76c9db2dec9d2561ffaa9be35f65403d53e984e8cd99c832383b7ab78c16c66"},
"sobelow": {:hex, :sobelow, "0.12.2", "45f4d500e09f95fdb5a7b94c2838d6b26625828751d9f1127174055a78542cf5", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "2f0b617dce551db651145662b84c8da4f158e7abe049a76daaaae2282df01c5d"},
"sourceror": {:hex, :sourceror, "0.12.3", "a2ad3a1a4554b486d8a113ae7adad5646f938cad99bf8bfcef26dc0c88e8fade", [:mix], [], "hexpm", "4d4e78010ca046524e8194ffc4683422f34a96f6b82901abbb45acc79ace0316"},
"spark": {:hex, :spark, "1.1.22", "68ba00f9acb4c8bc2c93ef82249493687ddf0f0a4f7e79c3c0e22b06719add56", [:mix], [{:nimble_options, "~> 0.5 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:sourceror, "~> 0.1", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "b798b95990eed8f2409df47b818b5dbcd00e9b5c30d0355465d0b04bbf9b5c4c"},
"sourceror": {:hex, :sourceror, "0.13.0", "c6ecc96ee3ae0e042e9082a9550a1989ea40182492dc29024a8d9d2b136e5014", [:mix], [], "hexpm", "d0a819491061cd26bfa4450d1c84301a410c19c1782a6577ce15853fc0e7e4e1"},
"spark": {:hex, :spark, "1.1.34", "c885265224ae0ae6f06415b65eb580589baa4c386f1faa5d07a2c88d80bed74d", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.5 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:sourceror, "~> 0.1", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "8e098de94948a5674b5ce9bec6c1674b55d18afb62831fea58f41a99b8d0f328"},
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"},
"stream_data": {:hex, :stream_data, "0.5.0", "b27641e58941685c75b353577dc602c9d2c12292dd84babf506c2033cd97893e", [:mix], [], "hexpm", "012bd2eec069ada4db3411f9115ccafa38540a3c78c4c0349f151fc761b9e271"},
"telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"},

View file

@ -263,4 +263,18 @@ defmodule AshStateMachineTest do
assert Exception.message(reason) =~ ~r/no next state/i
end
end
describe "possible_next_states/1" do
test "it correctly returns the next states" do
record = ThreeStates.create!(%{status: :complete})
assert [:executing, :pending] = AshStateMachine.possible_next_states(record)
end
end
describe "possible_next_states/2" do
test "it correctly returns the next states" do
record = ThreeStates.create!(%{status: :complete})
assert [:pending] = AshStateMachine.possible_next_states(record, :complete)
end
end
end