chore: fsm -> state_machine, event -> transition

This commit is contained in:
Zach Daniel 2023-04-21 22:33:28 -06:00
parent 6d0fa817a2
commit 06871a9977
15 changed files with 120 additions and 112 deletions

View file

@ -1,8 +1,8 @@
spark_locals_without_parens = [ spark_locals_without_parens = [
default_initial_state: 1, default_initial_state: 1,
deprecated_states: 1, deprecated_states: 1,
event: 1, transition: 1,
event: 2, transition: 2,
from: 1, from: 1,
initial_states: 1, initial_states: 1,
state_attribute: 1, state_attribute: 1,

2
.gitignore vendored
View file

@ -20,7 +20,7 @@ erl_crash.dump
*.ez *.ez
# Ignore package tarball (built via "mix hex.build"). # Ignore package tarball (built via "mix hex.build").
ash_fsm-*.tar ash_state_machine-*.tar
# Temporary files, for example, from tests. # Temporary files, for example, from tests.
/tmp/ /tmp/

View file

@ -1,21 +1,21 @@
# AshFsm # AshStateMachine
**TODO: Add description** **TODO: Add description**
## Installation ## Installation
If [available in Hex](https://hex.pm/docs/publish), the package can be installed If [available in Hex](https://hex.pm/docs/publish), the package can be installed
by adding `ash_fsm` to your list of dependencies in `mix.exs`: by adding `ash_state_machine` to your list of dependencies in `mix.exs`:
```elixir ```elixir
def deps do def deps do
[ [
{:ash_fsm, "~> 0.1.0"} {:ash_state_machine, "~> 0.1.0"}
] ]
end end
``` ```
Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc)
and published on [HexDocs](https://hexdocs.pm). Once published, the docs can and published on [HexDocs](https://hexdocs.pm). Once published, the docs can
be found at <https://hexdocs.pm/ash_fsm>. be found at <https://hexdocs.pm/ash_state_machine>.

View file

@ -1,11 +1,11 @@
defmodule AshFsm do defmodule AshStateMachine do
@moduledoc """ @moduledoc """
Documentation for `AshFsm`. Documentation for `AshStateMachine`.
""" """
defmodule Event do defmodule Transition do
@moduledoc """ @moduledoc """
The configuration for an event. The configuration for an transition.
""" """
@type t :: %__MODULE__{ @type t :: %__MODULE__{
action: atom, action: atom,
@ -16,15 +16,15 @@ defmodule AshFsm do
defstruct [:action, :from, :to] defstruct [:action, :from, :to]
end end
@event %Spark.Dsl.Entity{ @transition %Spark.Dsl.Entity{
name: :event, name: :transition,
target: Event, target: Transition,
args: [:action], args: [:action],
identifier: :action, identifier: :action,
schema: [ schema: [
action: [ action: [
type: :atom, type: :atom,
doc: "The corresponding action that is invoked for the event." doc: "The corresponding action that is invoked for the transition."
], ],
from: [ from: [
type: {:or, [{:list, :atom}, :atom]}, type: {:or, [{:list, :atom}, :atom]},
@ -39,23 +39,23 @@ defmodule AshFsm do
] ]
} }
@events %Spark.Dsl.Section{ @transitions %Spark.Dsl.Section{
name: :events, name: :transitions,
entities: [ entities: [
@event @transition
] ]
} }
@fsm %Spark.Dsl.Section{ @state_machine %Spark.Dsl.Section{
name: :fsm, name: :state_machine,
schema: [ schema: [
deprecated_states: [ deprecated_states: [
type: {:list, :atom}, type: {:list, :atom},
doc: """ doc: """
A list of states that have been deprecated. A list of states that have been deprecated.
The list of states is derived from the events normally. The list of states is derived from the transitions normally.
Use this option to express that certain types should still Use this option to express that certain types should still
be included even though no events go to/from that state anymore. be included even though no transitions go to/from that state anymore.
""" """
], ],
state_attribute: [ state_attribute: [
@ -74,44 +74,46 @@ defmodule AshFsm do
] ]
], ],
sections: [ sections: [
@events @transitions
] ]
} }
use Spark.Dsl.Extension, use Spark.Dsl.Extension,
sections: [@fsm], sections: [@state_machine],
transformers: [ transformers: [
AshFsm.Transformers.FillInEventDefaults, AshStateMachine.Transformers.FillInTransitionDefaults,
AshFsm.Transformers.AddState, AshStateMachine.Transformers.AddState,
AshFsm.Transformers.EnsureStateSelected AshStateMachine.Transformers.EnsureStateSelected
], ],
verifiers: [ verifiers: [
AshFsm.Verifiers.VerifyEventActions, AshStateMachine.Verifiers.VerifyTransitionActions,
AshFsm.Verifiers.VerifyDefaultInitialState AshStateMachine.Verifiers.VerifyDefaultInitialState
], ],
imports: [ imports: [
AshFsm.BuiltinChanges AshStateMachine.BuiltinChanges
] ]
def transition_state(%{action_type: :update} = changeset, target) do def transition_state(%{action_type: :update} = changeset, target) do
events = AshFsm.Info.fsm_events(changeset.resource, changeset.action.name) transitions =
attribute = AshFsm.Info.fsm_state_attribute!(changeset.resource) AshStateMachine.Info.state_machine_transitions(changeset.resource, changeset.action.name)
attribute = AshStateMachine.Info.state_machine_state_attribute!(changeset.resource)
old_state = Map.get(changeset.data, attribute) old_state = Map.get(changeset.data, attribute)
case Enum.find(events, fn event -> case Enum.find(transitions, fn transition ->
old_state in List.wrap(event.from) and target in List.wrap(event.to) old_state in List.wrap(transition.from) and target in List.wrap(transition.to)
end) do end) do
nil -> nil ->
Ash.Changeset.add_error( Ash.Changeset.add_error(
changeset, changeset,
AshFsm.Errors.NoMatchingEvent.exception( AshStateMachine.Errors.NoMatchingTransition.exception(
from: old_state, from: old_state,
target: target, target: target,
action: changeset.action.name action: changeset.action.name
) )
) )
_event -> _transition ->
Ash.Changeset.force_change_attribute(changeset, attribute, target) Ash.Changeset.force_change_attribute(changeset, attribute, target)
end end
end end

View file

@ -1,12 +1,12 @@
defmodule AshFsm.BuiltinChanges do defmodule AshStateMachine.BuiltinChanges do
@moduledoc """ @moduledoc """
Changes for working with AshFsm resources. Changes for working with AshStateMachine resources.
""" """
@doc """ @doc """
Changes the state to the target state, validating the transition Changes the state to the target state, validating the transition
""" """
def transition_state(target) do def transition_state(target) do
{AshFsm.BuiltinChanges.TransitionState, target: target} {AshStateMachine.BuiltinChanges.TransitionState, target: target}
end end
end end

View file

@ -1,7 +1,7 @@
defmodule AshFsm.BuiltinChanges.TransitionState do defmodule AshStateMachine.BuiltinChanges.TransitionState do
use Ash.Resource.Change use Ash.Resource.Change
def change(changeset, opts, _) do def change(changeset, opts, _) do
AshFsm.transition_state(changeset, opts[:target]) AshStateMachine.transition_state(changeset, opts[:target])
end end
end end

View file

@ -1,5 +1,5 @@
defmodule AshFsm.Errors.NoMatchingEvent do defmodule AshStateMachine.Errors.NoMatchingTransition do
@moduledoc "Used when a state change occurs in an action with no matching event" @moduledoc "Used when a state change occurs in an action with no matching transition"
use Ash.Error.Exception use Ash.Error.Exception
def_ash_error([:action, :target, :old_state], class: :invalid) def_ash_error([:action, :target, :old_state], class: :invalid)
@ -7,11 +7,11 @@ defmodule AshFsm.Errors.NoMatchingEvent do
defimpl Ash.ErrorKind do defimpl Ash.ErrorKind do
def id(_), do: Ash.UUID.generate() def id(_), do: Ash.UUID.generate()
def code(_), do: "no_matching_event" def code(_), do: "no_matching_transition"
def message(error) do def message(error) do
""" """
Attempted to change state from #{error.old_state} to #{error.target} in action #{error.action}, but no matching event was configured. Attempted to change state from #{error.old_state} to #{error.target} in action #{error.action}, but no matching transition was configured.
""" """
end end
end end

View file

@ -1,15 +1,16 @@
defmodule AshFsm.Info do defmodule AshStateMachine.Info do
use Spark.InfoGenerator, extension: AshFsm, sections: [:fsm] use Spark.InfoGenerator, extension: AshStateMachine, sections: [:state_machine]
@spec fsm_events(Ash.Resource.record() | map(), name :: atom) :: list(AshFsm.Event.t()) @spec state_machine_transitions(Ash.Resource.record() | map(), name :: atom) ::
def fsm_events(resource_or_dsl, name) do list(AshStateMachine.Transition.t())
def state_machine_transitions(resource_or_dsl, name) do
resource_or_dsl resource_or_dsl
|> fsm_events() |> state_machine_transitions()
|> Enum.filter(&(&1.action == name)) |> Enum.filter(&(&1.action == name))
end end
@spec fsm_all_states(Ash.Resource.record() | map()) :: list(atom) @spec state_machine_all_states(Ash.Resource.record() | map()) :: list(atom)
def fsm_all_states(resource_or_dsl) do def state_machine_all_states(resource_or_dsl) do
Spark.Dsl.Extension.get_persisted(resource_or_dsl, :all_fsm_states, []) Spark.Dsl.Extension.get_persisted(resource_or_dsl, :all_state_machine_states, [])
end end
end end

View file

@ -1,4 +1,4 @@
defmodule AshFsm.Transformers.AddState do defmodule AshStateMachine.Transformers.AddState do
use Spark.Dsl.Transformer use Spark.Dsl.Transformer
alias Spark.Dsl.Transformer alias Spark.Dsl.Transformer
@ -6,19 +6,21 @@ defmodule AshFsm.Transformers.AddState do
def transform(dsl_state) do def transform(dsl_state) do
deprecated_states = deprecated_states =
case AshFsm.Info.fsm_deprecated_states(dsl_state) do case AshStateMachine.Info.state_machine_deprecated_states(dsl_state) do
{:ok, value} -> value || [] {:ok, value} -> value || []
_ -> [] _ -> []
end end
all_states = Enum.uniq(AshFsm.Info.fsm_all_states(dsl_state) ++ deprecated_states) all_states =
attribute_name = AshFsm.Info.fsm_state_attribute!(dsl_state) Enum.uniq(AshStateMachine.Info.state_machine_all_states(dsl_state) ++ deprecated_states)
attribute_name = AshStateMachine.Info.state_machine_state_attribute!(dsl_state)
module = Transformer.get_persisted(dsl_state, :module) module = Transformer.get_persisted(dsl_state, :module)
case Ash.Resource.Info.attribute(dsl_state, attribute_name) do case Ash.Resource.Info.attribute(dsl_state, attribute_name) do
nil -> nil ->
default = default =
case AshFsm.Info.fsm_default_initial_state(dsl_state) do case AshStateMachine.Info.state_machine_default_initial_state(dsl_state) do
{:ok, value} -> {:ok, value} ->
value value
@ -42,7 +44,7 @@ defmodule AshFsm.Transformers.AddState do
{attribute.type, attribute.constraints} {attribute.type, attribute.constraints}
end end
case AshFsm.Info.fsm_default_initial_state(dsl_state) do case AshStateMachine.Info.state_machine_default_initial_state(dsl_state) do
{:ok, default} -> {:ok, default} ->
if attribute.default != default do if attribute.default != default do
raise Spark.Error.DslError, raise Spark.Error.DslError,

View file

@ -1,4 +1,4 @@
defmodule AshFsm.Transformers.EnsureStateSelected do defmodule AshStateMachine.Transformers.EnsureStateSelected do
use Spark.Dsl.Transformer use Spark.Dsl.Transformer
def transform(dsl_state) do def transform(dsl_state) do
@ -6,7 +6,7 @@ defmodule AshFsm.Transformers.EnsureStateSelected do
dsl_state, dsl_state,
{Ash.Resource.Preparation.Build, {Ash.Resource.Preparation.Build,
ensure_selected: [ ensure_selected: [
AshFsm.Info.fsm_state_attribute(dsl_state) AshStateMachine.Info.state_machine_state_attribute(dsl_state)
]} ]}
) )
end end

View file

@ -1,11 +1,11 @@
defmodule AshFsm.Transformers.FillInEventDefaults do defmodule AshStateMachine.Transformers.FillInTransitionDefaults do
use Spark.Dsl.Transformer use Spark.Dsl.Transformer
alias Spark.Dsl.Transformer alias Spark.Dsl.Transformer
@moduledoc false @moduledoc false
def transform(dsl_state) do def transform(dsl_state) do
initial_states = initial_states =
case AshFsm.Info.fsm_initial_states(dsl_state) do case AshStateMachine.Info.state_machine_initial_states(dsl_state) do
{:ok, value} -> List.wrap(value) {:ok, value} -> List.wrap(value)
_ -> [] _ -> []
end end
@ -13,7 +13,7 @@ defmodule AshFsm.Transformers.FillInEventDefaults do
initial_states = initial_states =
case initial_states do case initial_states do
[] -> [] ->
case AshFsm.Info.fsm_default_initial_state(dsl_state) do case AshStateMachine.Info.state_machine_default_initial_state(dsl_state) do
{:ok, value} when not is_nil(value) -> {:ok, value} when not is_nil(value) ->
[value] [value]
@ -25,36 +25,36 @@ defmodule AshFsm.Transformers.FillInEventDefaults do
initial_states initial_states
end end
events = transitions =
dsl_state dsl_state
|> AshFsm.Info.fsm_events() |> AshStateMachine.Info.state_machine_transitions()
all_states = all_states =
events transitions
|> Enum.flat_map(fn event -> |> Enum.flat_map(fn transition ->
List.wrap(event.from) ++ List.wrap(event.to) List.wrap(transition.from) ++ List.wrap(transition.to)
end) end)
|> Enum.concat(List.wrap(initial_states)) |> Enum.concat(List.wrap(initial_states))
|> Enum.uniq() |> Enum.uniq()
dsl_state = dsl_state =
case AshFsm.Info.fsm_initial_states(dsl_state) do case AshStateMachine.Info.state_machine_initial_states(dsl_state) do
{:ok, value} when not is_nil(value) and value != [] -> {:ok, value} when not is_nil(value) and value != [] ->
dsl_state dsl_state
_ -> _ ->
Transformer.set_option(dsl_state, [:fsm], :initial_states, all_states) Transformer.set_option(dsl_state, [:state_machine], :initial_states, all_states)
end end
{:ok, {:ok,
events transitions
|> Enum.reduce(dsl_state, fn event, dsl_state -> |> Enum.reduce(dsl_state, fn transition, dsl_state ->
Transformer.replace_entity(dsl_state, [:fsm], %{ Transformer.replace_entity(dsl_state, [:state_machine], %{
event transition
| from: event.from || all_states, | from: transition.from || all_states,
to: event.to || all_states to: transition.to || all_states
}) })
end) end)
|> Transformer.persist(:all_fsm_states, all_states)} |> Transformer.persist(:all_state_machine_states, all_states)}
end end
end end

View file

@ -1,15 +1,18 @@
defmodule AshFsm.Verifiers.VerifyDefaultInitialState do defmodule AshStateMachine.Verifiers.VerifyDefaultInitialState do
use Spark.Dsl.Verifier use Spark.Dsl.Verifier
def verify(dsl_state) do def verify(dsl_state) do
module = Spark.Dsl.Verifier.get_persisted(dsl_state, :module) module = Spark.Dsl.Verifier.get_persisted(dsl_state, :module)
attribute = attribute =
Ash.Resource.Info.attribute(dsl_state, AshFsm.Info.fsm_state_attribute!(dsl_state)) Ash.Resource.Info.attribute(
dsl_state,
AshStateMachine.Info.state_machine_state_attribute!(dsl_state)
)
case AshFsm.Info.fsm_default_initial_state(dsl_state) do case AshStateMachine.Info.state_machine_default_initial_state(dsl_state) do
{:ok, initial} when not is_nil(initial) -> {:ok, initial} when not is_nil(initial) ->
initial_states = AshFsm.Info.fsm_initial_states!(dsl_state) initial_states = AshStateMachine.Info.state_machine_initial_states!(dsl_state)
unless initial in initial_states do unless initial in initial_states do
raise Spark.Error.DslError, raise Spark.Error.DslError,

View file

@ -1,18 +1,18 @@
defmodule AshFsm.Verifiers.VerifyEventActions do defmodule AshStateMachine.Verifiers.VerifyTransitionActions do
use Spark.Dsl.Verifier use Spark.Dsl.Verifier
def verify(dsl_state) do def verify(dsl_state) do
dsl_state dsl_state
|> AshFsm.Info.fsm_events() |> AshStateMachine.Info.state_machine_transitions()
|> Enum.each(fn event -> |> Enum.each(fn transition ->
action = Ash.Resource.Info.action(dsl_state, event.action) action = Ash.Resource.Info.action(dsl_state, transition.action)
unless action && action.type == :update do unless action && action.type == :update do
raise Spark.Error.DslError, raise Spark.Error.DslError,
module: Spark.Dsl.Verifier.get_persisted(dsl_state, :module), module: Spark.Dsl.Verifier.get_persisted(dsl_state, :module),
path: [:fsm, :events, :event, event.action], path: [:state_machine, :transitions, :transition, transition.action],
message: """ message: """
Event configured with action `:#{event.action}` but no such update action is defined. Transition configured with action `:#{transition.action}` but no such update action is defined.
""" """
end end
end) end)

22
mix.exs
View file

@ -1,4 +1,4 @@
defmodule AshFsm.MixProject do defmodule AshStateMachine.MixProject do
use Mix.Project use Mix.Project
@version "0.1.0" @version "0.1.0"
@ -9,7 +9,7 @@ defmodule AshFsm.MixProject do
def project do def project do
[ [
app: :ash_fsm, app: :ash_state_machine,
version: @version, version: @version,
elixir: "~> 1.14", elixir: "~> 1.14",
start_permanent: Mix.env() == :prod, start_permanent: Mix.env() == :prod,
@ -20,19 +20,19 @@ defmodule AshFsm.MixProject do
dialyzer: [plt_add_apps: [:ash]], dialyzer: [plt_add_apps: [:ash]],
docs: docs(), docs: docs(),
description: @description, description: @description,
source_url: "https://github.com/ash-project/ash_fsm", source_url: "https://github.com/ash-project/ash_state_machine",
homepage_url: "https://github.com/ash-project/ash_fsm" homepage_url: "https://github.com/ash-project/ash_state_machine"
] ]
end end
defp package do defp package do
[ [
name: :ash_fsm, name: :ash_state_machine,
licenses: ["MIT"], licenses: ["MIT"],
files: ~w(lib .formatter.exs mix.exs README* LICENSE* files: ~w(lib .formatter.exs mix.exs README* LICENSE*
CHANGELOG* documentation), CHANGELOG* documentation),
links: %{ links: %{
GitHub: "https://github.com/ash-project/ash_fsm" GitHub: "https://github.com/ash-project/ash_state_machine"
} }
] ]
end end
@ -87,17 +87,17 @@ defmodule AshFsm.MixProject do
defp docs do defp docs do
[ [
main: "AshFsm", main: "AshStateMachine",
source_ref: "v#{@version}", source_ref: "v#{@version}",
logo: "logos/small-logo.png", logo: "logos/small-logo.png",
extra_section: "GUIDES", extra_section: "GUIDES",
spark: [ spark: [
extensions: [ extensions: [
%{ %{
module: AshFsm, module: AshStateMachine,
name: "AshFsm", name: "AshStateMachine",
target: "Ash.Resource", target: "Ash.Resource",
type: "Fsm Resource" type: "StateMachine Resource"
}, },
%{ %{
module: AshGraphql.Api, module: AshGraphql.Api,
@ -147,7 +147,7 @@ defmodule AshFsm.MixProject do
sobelow: "sobelow --skip", sobelow: "sobelow --skip",
credo: "credo --strict", credo: "credo --strict",
docs: ["docs", "ash.replace_doc_links"], docs: ["docs", "ash.replace_doc_links"],
"spark.formatter": "spark.formatter --extensions AshFsm" "spark.formatter": "spark.formatter --extensions AshStateMachine"
] ]
end end
end end

View file

@ -1,18 +1,18 @@
defmodule AshFsmTest do defmodule AshStateMachineTest do
use ExUnit.Case use ExUnit.Case
doctest AshFsm doctest AshStateMachine
defmodule ThreeStates do defmodule ThreeStates do
use Ash.Resource, use Ash.Resource,
data_layer: Ash.DataLayer.Ets, data_layer: Ash.DataLayer.Ets,
extensions: [AshFsm] extensions: [AshStateMachine]
fsm do state_machine do
default_initial_state :pending default_initial_state :pending
events do transitions do
event :begin, from: :pending, to: :executing transition(:begin, from: :pending, to: :executing)
event :complete, from: :executing, to: :complete transition(:complete, from: :executing, to: :complete)
end end
end end
@ -37,7 +37,7 @@ defmodule AshFsmTest do
end end
code_interface do code_interface do
define_for AshFsmTest.Api define_for AshStateMachineTest.Api
define :create define :create
define :begin define :begin
define :complete define :complete
@ -54,7 +54,7 @@ defmodule AshFsmTest do
describe "transformers" do describe "transformers" do
test "infers all states" do test "infers all states" do
assert Enum.sort(AshFsm.Info.fsm_all_states(ThreeStates)) == assert Enum.sort(AshStateMachine.Info.state_machine_all_states(ThreeStates)) ==
Enum.sort([:executing, :pending, :complete]) Enum.sort([:executing, :pending, :complete])
end end
end end
@ -65,15 +65,15 @@ defmodule AshFsmTest do
end end
test "it transitions to the appropriate state" do test "it transitions to the appropriate state" do
fsm = ThreeStates.create!() state_machine = ThreeStates.create!()
assert ThreeStates.begin!(fsm).state == :executing assert ThreeStates.begin!(state_machine).state == :executing
end end
test "it transitions again to the appropriate state" do test "it transitions again to the appropriate state" do
fsm = ThreeStates.create!() |> ThreeStates.begin!() state_machine = ThreeStates.create!() |> ThreeStates.begin!()
assert ThreeStates.complete!(fsm).state == :complete assert ThreeStates.complete!(state_machine).state == :complete
end end
end end
end end