diff --git a/lib/builtin_changes/builtin_changes.ex b/lib/builtin_changes/builtin_changes.ex index 7411959..3603985 100644 --- a/lib/builtin_changes/builtin_changes.ex +++ b/lib/builtin_changes/builtin_changes.ex @@ -9,4 +9,9 @@ defmodule AshStateMachine.BuiltinChanges do def transition_state(target) do {AshStateMachine.BuiltinChanges.TransitionState, target: target} end + + @doc """ + Try and transition to the next state. + """ + def next_state, do: AshStateMachine.BuiltinChanges.NextState end diff --git a/lib/builtin_changes/next_state.ex b/lib/builtin_changes/next_state.ex new file mode 100644 index 0000000..2e19157 --- /dev/null +++ b/lib/builtin_changes/next_state.ex @@ -0,0 +1,39 @@ +defmodule AshStateMachine.BuiltinChanges.NextState do + @moduledoc """ + Given the action and the current state, attempt to find the next state to + transition into. + """ + 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 + %{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 == :*)) + |> case do + [to] -> + AshStateMachine.transition_state(changeset, to) + + [] -> + Ash.Changeset.add_error(changeset, "Cannot determine next state: no next state available") + + _ -> + Ash.Changeset.add_error( + changeset, + "Cannot determine next state: multiple next states available" + ) + end + end +end diff --git a/lib/info.ex b/lib/info.ex index 9593d24..45c0476 100644 --- a/lib/info.ex +++ b/lib/info.ex @@ -2,7 +2,7 @@ defmodule AshStateMachine.Info do @moduledoc "Introspection helpers for `AshStateMachine`" use Spark.InfoGenerator, extension: AshStateMachine, sections: [:state_machine] - @spec state_machine_transitions(Ash.Resource.record() | map(), name :: atom) :: + @spec state_machine_transitions(Ash.Resource.t() | map(), name :: atom) :: list(AshStateMachine.Transition.t()) def state_machine_transitions(resource_or_dsl, name) do resource_or_dsl @@ -10,7 +10,7 @@ defmodule AshStateMachine.Info do |> Enum.filter(&(&1.action == :* || &1.action == name)) end - @spec state_machine_all_states(Ash.Resource.record() | map()) :: list(atom) + @spec state_machine_all_states(Ash.Resource.t() | map()) :: list(atom) def state_machine_all_states(resource_or_dsl) do Spark.Dsl.Extension.get_persisted(resource_or_dsl, :all_state_machine_states, []) end diff --git a/test/ash_state_machine_test.exs b/test/ash_state_machine_test.exs index 53858f9..f538ed4 100644 --- a/test/ash_state_machine_test.exs +++ b/test/ash_state_machine_test.exs @@ -166,4 +166,65 @@ defmodule AshStateMachineTest do |> String.trim_trailing() end end + + describe "next state" do + defmodule NextStateMachine do + @moduledoc false + use Ash.Resource, + extensions: [AshStateMachine] + + state_machine do + initial_states [:a] + default_initial_state :a + + transitions do + transition :next, from: :a, to: :b + transition :next, from: :b, to: :c + transition :next, from: :b, to: :d + end + end + + attributes do + uuid_primary_key :id + + attribute :state, :atom do + allow_nil? false + constraints one_of: [:a, :b, :c, :d] + default :a + end + end + + actions do + defaults [:create] + + update :next do + change next_state() + end + end + + code_interface do + define_for Api + define :create + define :next + end + end + + test "when there is only one next state, it transitions into it" do + assert {:ok, nsm} = NextStateMachine.create(%{state: :a}) + assert {:ok, nsm} = NextStateMachine.next(nsm) + assert nsm.state == :b + end + + test "when there is more than one next state, it makes an oopsie" do + assert {:ok, nsm} = NextStateMachine.create(%{state: :b}) + assert {:error, reason} = NextStateMachine.next(nsm) + assert Exception.message(reason) =~ ~r/multiple next states/i + end + + test "when there are no next states available, it also makes an oopsie" do + assert {:ok, nsm} = NextStateMachine.create(%{state: :c}) + assert {:error, reason} = NextStateMachine.next(nsm) + assert Exception.message(reason) =~ ~r/no next state/i + end + end end