From 5cba78616c8f41d349a40d8f465052641fe06f2f Mon Sep 17 00:00:00 2001 From: James Harton <59449+jimsynz@users.noreply.github.com> Date: Fri, 15 Sep 2023 15:15:29 +1200 Subject: [PATCH] improvement: Add `possible_next_states` helper. (#9) * improvement: Add `possible_next_states` helper. * chore: add `mix spark.cheat_sheets`. --- .formatter.exs | 1 + .../dsls/DSL:-AshStateMachine.cheatmd | 148 ++++++++++++++++++ lib/ash_state_machine.ex | 36 +++++ lib/builtin_changes/next_state.ex | 19 +-- mix.exs | 3 +- mix.lock | 4 +- test/ash_state_machine_test.exs | 14 ++ 7 files changed, 205 insertions(+), 20 deletions(-) create mode 100644 documentation/dsls/DSL:-AshStateMachine.cheatmd diff --git a/.formatter.exs b/.formatter.exs index 3813d0b..2fa4d64 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -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, diff --git a/documentation/dsls/DSL:-AshStateMachine.cheatmd b/documentation/dsls/DSL:-AshStateMachine.cheatmd new file mode 100644 index 0000000..3c1fc62 --- /dev/null +++ b/documentation/dsls/DSL:-AshStateMachine.cheatmd @@ -0,0 +1,148 @@ +# DSL: AshStateMachine + +Functions for working with AshStateMachine. + + +## 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. + + + + + + + + + + + + +## 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` + + + + + + diff --git a/lib/ash_state_machine.ex b/lib/ash_state_machine.ex index 1060323..58e1b4b 100644 --- a/lib/ash_state_machine.ex +++ b/lib/ash_state_machine.ex @@ -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 diff --git a/lib/builtin_changes/next_state.ex b/lib/builtin_changes/next_state.ex index 034df22..33101a0 100644 --- a/lib/builtin_changes/next_state.ex +++ b/lib/builtin_changes/next_state.ex @@ -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) diff --git a/mix.exs b/mix.exs index 4ac973f..53f1671 100644 --- a/mix.exs +++ b/mix.exs @@ -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 diff --git a/mix.lock b/mix.lock index 1df2c30..88c20c6 100644 --- a/mix.lock +++ b/mix.lock @@ -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"}, diff --git a/test/ash_state_machine_test.exs b/test/ash_state_machine_test.exs index b164e3f..277d8db 100644 --- a/test/ash_state_machine_test.exs +++ b/test/ash_state_machine_test.exs @@ -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