Skip to content

Commit

Permalink
Allows for multiple states machines in the same struct (#16)
Browse files Browse the repository at this point in the history
* Allows for multiple states machines in the same struct

Why:

* In certain situations you might want to allow for multiple state
  machines in the same struct.

  See #13

This change addresses the need by:

* Allowing for Fsmx.Struct to be included multiple times per module,
  with a different `state_field`, so it runs independently. When calling
  any of the transition function you can optionally pass it the field
  name, so you can control which one to transition. All of the other
  options, including `transitions` are also independent
* Passing the `state_field` to the callbacks as well, so that you can
  have them be different depending on the which field is being
  transitionned. As of now, the old version is still supported, but it
  is encouraged that you use the new version, which will make it simpler
  to add new fields if you wish.

* Bump the elixir versions we test against

* Use OTP version 25

* Adds the new functionality to the README

* Bump the min elixir version supported and the minor version

* Corrects typespecs and uses `state_field` everywhere
  • Loading branch information
zamith authored Aug 9, 2023
1 parent 76c1ea6 commit 0a3c07f
Show file tree
Hide file tree
Showing 17 changed files with 285 additions and 60 deletions.
4 changes: 2 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,11 @@ jobs:
test:
strategy:
matrix:
elixir: ['1.8', '1.9', '1.10']
elixir: ['1.13', '1.14', '1.15']

runs-on: ubuntu-latest
container:
image: elixir:${{ matrix.elixir }}-alpine
image: elixir:${{ matrix.elixir }}-otp-25-alpine
env:
POSTGRES_HOST: postgres
MIX_ENV: test
Expand Down
41 changes: 40 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ defmodule App.StateMachine do
"two" => ["three", "four"],
"three" => "four",
"four" => :*, # can transition to any state
"*" => ["five"] # can transition from any state to "five"
:* => ["five"] # can transition from any state to "five"
}
end
```
Expand Down Expand Up @@ -148,6 +148,45 @@ defmodule App.BusinessLogic do
end
```

### Multiple state machines in the same struct

Not all structs have a single state machine, sometimes you might need more,
using different fields for that effect. Here's how you can do it:

```elixir
defmodule App.StateMachine do
defstruct [:state, :other_state, :data]

use Fsmx.Struct, transitions: %{
"one" => ["two", "three"],
"two" => ["three", "four"],
"three" => "four",
"four" => :*, # can transition to any state
:* => ["five"] # can transition from any state to "five"
}

use Fsmx.Struct,
state_field: :other_state,
transitions: %{
"initial" => ["middle", "middle2"],
"middle" => "middle2",
:* => "final"
}
end
```

Use it via the `Fsmx.transition/3` function:

```elixir
struct = %App.StateMachine{state: "one", other_state: "initial", data: nil}

Fsmx.transition(struct, "two")
# {:ok, %App.StateMachine{state: "two", other_state: "initial"}}

Fsmx.transition(struct, "final", field: :other_state)
# {:ok, %App.StateMachine{state: "one", other_state: "final"}}
```

## Ecto support

Support for Ecto is built in, as long as `ecto` is in your `mix.exs` dependencies. With it, you get the ability to
Expand Down
4 changes: 2 additions & 2 deletions config/config.exs
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
use Mix.Config
import Config

import_config "#{Mix.env()}.exs"
import_config "#{config_env()}.exs"
2 changes: 1 addition & 1 deletion config/dev.exs
Original file line number Diff line number Diff line change
@@ -1 +1 @@
use Mix.Config
import Config
2 changes: 1 addition & 1 deletion config/prod.exs
Original file line number Diff line number Diff line change
@@ -1 +1 @@
use Mix.Config
import Config
2 changes: 1 addition & 1 deletion config/test.exs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use Mix.Config
import Config

config :fsmx, ecto_repos: [Fsmx.Repo]

Expand Down
83 changes: 56 additions & 27 deletions lib/fsmx.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,40 @@ defmodule Fsmx do
@moduledoc """
"""

alias Fsmx.Fsm

@type state_t :: binary() | atom()
@type opts_t :: [state_field: atom()]

@spec transition(struct(), state_t(), opts_t()) :: {:ok, struct} | {:error, any}
def transition(%{} = struct, new_state, opts \\ []) do
opts = Keyword.put_new(opts, :state_field, Fsm.default_state_field())
state_field = Keyword.get(opts, :state_field)

@spec transition(struct(), state_t()) :: {:ok, struct} | {:error, any}
def transition(%mod{} = struct, new_state) do
fsm = mod.__fsmx__()
with {:ok, struct} <- before_transition(struct, new_state) do
state_field = fsm.__fsmx__(:state_field)
{:ok, struct |> Map.put(state_field, new_state)}
with {:ok, struct} <- before_transition(struct, new_state, state_field) do
{:ok, struct |> Map.put(state_field, new_state)}
end
end

if Code.ensure_loaded?(Ecto) do
@spec transition_changeset(struct(), state_t(), map) :: Ecto.Changeset.t()
def transition_changeset(%mod{} = schema, new_state, params \\ %{}) do
fsm = mod.__fsmx__()
state_field = fsm.__fsmx__(:state_field)
@spec transition_changeset(struct(), state_t(), map, opts_t()) :: Ecto.Changeset.t()
def transition_changeset(%mod{} = schema, new_state, params \\ %{}, opts \\ []) do
opts = Keyword.put_new(opts, :state_field, Fsm.default_state_field())
state_field = Keyword.get(opts, :state_field)
state = schema |> Map.fetch!(state_field)
fsm = mod.__fsmx__(state_field)

with {:ok, schema} <- before_transition(schema, new_state) do
with {:ok, schema} <- before_transition(schema, new_state, state_field) do
schema
|> Ecto.Changeset.change()
|> Ecto.Changeset.put_change(state_field, new_state)
|> fsm.transition_changeset(state, new_state, params)
|> then(fn changeset ->
if function_exported?(fsm, :transition_changeset, 4) do
fsm.transition_changeset(changeset, state, new_state, params)
else
fsm.transition_changeset(changeset, state, new_state, params, state_field)
end
end)
else
{:error, msg} ->
schema
Expand All @@ -33,39 +44,54 @@ defmodule Fsmx do
end
end

@spec transition_multi(Ecto.Multi.t(), struct(), any, state_t, map) :: Ecto.Multi.t()
def transition_multi(multi, %mod{} = schema, id, new_state, params \\ %{}) do
fsm = mod.__fsmx__()
state = schema |> Map.fetch!(fsm.__fsmx__(:state_field))
@spec transition_multi(Ecto.Multi.t(), struct(), any, state_t, map, opts_t()) ::
Ecto.Multi.t()
def transition_multi(multi, %mod{} = schema, id, new_state, params \\ %{}, opts \\ []) do
opts = Keyword.put_new(opts, :state_field, Fsm.default_state_field())
state_field = Keyword.get(opts, :state_field)
state = schema |> Map.fetch!(state_field)
fsm = mod.__fsmx__(state_field)

changeset = transition_changeset(schema, new_state, params)
changeset = transition_changeset(schema, new_state, params, state_field: state_field)

multi
|> Ecto.Multi.update(id, changeset)
|> Ecto.Multi.run("#{id}-callback", fn _repo, changes ->
fsm.after_transition_multi(Map.fetch!(changes, id), state, new_state)
if function_exported?(fsm, :after_transition_multi, 3) do
fsm.after_transition_multi(Map.fetch!(changes, id), state, new_state)
else
fsm.after_transition_multi(Map.fetch!(changes, id), state, new_state, state_field)
end
end)
end
end

defp before_transition(%mod{} = struct, new_state) do
fsm = mod.__fsmx__()
state = struct |> Map.fetch!(fsm.__fsmx__(:state_field))
transitions = fsm.__fsmx__(:transitions)
defp before_transition(%mod{} = struct, new_state, state_field) do
fsm = mod.__fsmx__(state_field)
state = struct |> Map.fetch!(state_field)
transitions = fsm.__fsmx__(state_field, :transitions)

with :ok <- validate_transition(state, new_state, transitions) do
fsm.before_transition(struct, state, new_state)
with :ok <- validate_transition(state, new_state, transitions, state_field) do
if function_exported?(fsm, :before_transition, 3) do
fsm.before_transition(struct, state, new_state)
else
fsm.before_transition(struct, state, new_state, state_field)
end
end
end

defp validate_transition(state, new_state, transitions) do
defp validate_transition(state, new_state, transitions, state_field) do
transitions
|> from_source_or_fallback(state)
|> is_or_contains?(new_state)
|> if do
:ok
else
{:error, "invalid transition from #{state} to #{new_state}"}
if state_field == Fsm.default_state_field() do
{:error, "invalid transition from #{state} to #{new_state}"}
else
{:error, "invalid transition from #{state} to #{new_state} for field #{state_field}"}
end
end
end

Expand All @@ -79,6 +105,9 @@ defmodule Fsmx do

defp is_or_contains?(:*, _), do: true
defp is_or_contains?(state, state), do: true
defp is_or_contains?(states, state) when is_list(states), do: Enum.member?(states, state) || Enum.member?(states, :*)

defp is_or_contains?(states, state) when is_list(states),
do: Enum.member?(states, state) || Enum.member?(states, :*)

defp is_or_contains?(_, _), do: false
end
42 changes: 31 additions & 11 deletions lib/fsmx/fsm.ex
Original file line number Diff line number Diff line change
Expand Up @@ -13,39 +13,59 @@ defmodule Fsmx.Fsm do
defmodule MyApp.Fsm do
use Fsmx.Fsm, transitions: %{}
def before_transition(struct, _from, _to) do
def before_transition(struct, _from, _to, _state_field) do
# ...
end
end
```
## Callbacks
Callbacks are defined as functions in the module that includes `Fsmx.Fsm`
(which could be the one that includes `Fsmx.Struct`). They are called with
the struct, the current state, the new state, and the state field name. This
allows for different callbacks per state field.
The following callbacks are deprecated and will be removed in a future:
- `before_transition/3`
- `transition_changeset/4`
- `after_transition_multi/3`
"""

@callback before_transition(struct, Fsmx.state_t, Fsmx.state_t) :: {:ok, struct} | {:error, any}
@callback before_transition(struct, Fsmx.state_t(), Fsmx.state_t(), atom()) ::
{:ok, struct} | {:error, any}

if Code.ensure_loaded?(Ecto) do
@callback transition_changeset(struct, Fsmx.state_t, Fsmx.state_t) :: Ecto.Changeset.t()
@callback after_transition_multi(struct, Fsmx.state_t, Fsmx.state_t) ::
@callback transition_changeset(Ecto.Schema.t(), Fsmx.state_t(), Fsmx.state_t(), map(), atom()) ::
Ecto.Changeset.t()
@callback after_transition_multi(struct, Fsmx.state_t(), Fsmx.state_t(), atom()) ::
{:ok, struct} | {:error, any}
end

def default_state_field do
:state
end

defmacro __using__(opts \\ []) do
{state_field, _} = Code.eval_quoted(Keyword.get(opts, :state_field, :state))

quote do
@before_compile unquote(__MODULE__)

@fsm Keyword.get(unquote(opts), :fsm, __MODULE__)
def __fsmx__(unquote(state_field), :transitions),
do: Keyword.fetch!(unquote(opts), :transitions)

def __fsmx__(:state_field), do: Keyword.get(unquote(opts), :state_field, :state)
def __fsmx__(:transitions), do: Keyword.fetch!(unquote(opts), :transitions)
def __fsmx__(:fsm), do: @fsm
def __fsmx__(unquote(state_field), :fsm), do: Keyword.get(unquote(opts), :fsm, __MODULE__)
end
end

defmacro __before_compile__(_env) do
quote generated: false do
def before_transition(struct, _from, _to), do: {:ok, struct}
def before_transition(struct, _from, _to, _state_field), do: {:ok, struct}

if Code.ensure_loaded?(Ecto) do
def transition_changeset(changeset, _from, _to, _params), do: changeset
def after_transition_multi(struct, _from, _to), do: {:ok, struct}
def transition_changeset(changeset, _from, _to, _params, _state_field), do: changeset
def after_transition_multi(struct, _from, _to, _state_field), do: {:ok, struct}
end
end
end
Expand Down
33 changes: 29 additions & 4 deletions lib/fsmx/struct.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ defmodule Fsmx.Struct do
@moduledoc """
Main module to include finite-state machine logic into your struct/schema
It assumes a `:state` string field exists in your model
If no `state_field` is defined, it assumes the name is `:state`.
Basic usage:
Expand All @@ -13,17 +13,42 @@ defmodule Fsmx.Struct do
use Fsmx.Struct, transitions: %{}
end
```
You can also specify a custom state field:
```elixir
defmodule MyApp.Struct do
defstruct [:my_state]
use Fsmx.Struct, state_field: :my_state, transitions: %{}
end
```
Or even multiple state fields, that will behave independently and have their
own transition definition, etc. In this case `:state` is still used as the
default:
```elixir
defmodule MyApp.Struct do
defstruct [:state, :other_state]
use Fsmx.Struct, transitions: %{}
use Fsmx.Struct, state_field: :other_state, transitions: %{}
end
```
"""

defmacro __using__(opts \\ []) do
{state_field, _} = Code.eval_quoted(Keyword.get(opts, :state_field, :state))

quote do
@fsm Keyword.get(unquote(opts), :fsm, __MODULE__)
fsm = Keyword.get(unquote(opts), :fsm, __MODULE__)

if @fsm == __MODULE__ do
if fsm == __MODULE__ do
use Fsmx.Fsm, unquote(opts)
end

def __fsmx__, do: @fsm
def __fsmx__(unquote(state_field)), do: Keyword.get(unquote(opts), :fsm, __MODULE__)
end
end
end
4 changes: 2 additions & 2 deletions mix.exs
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
defmodule Fsmx.MixProject do
use Mix.Project

@version "0.4.1"
@version "0.5.0"

def project do
[
app: :fsmx,
version: @version,
elixir: "~> 1.8",
elixir: "~> 1.11",
elixirc_paths: elixirc_paths(Mix.env()),
start_permanent: Mix.env() == :prod,
applications: applications(Mix.env()),
Expand Down
8 changes: 4 additions & 4 deletions mix.lock
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
%{
"connection": {:hex, :connection, "1.1.0", "ff2a49c4b75b6fb3e674bfc5536451607270aac754ffd1bdfe175abe4a6d7a68", [:mix], [], "hexpm", "722c1eb0a418fbe91ba7bd59a47e28008a189d47e37e0e7bb85585a016b2869c"},
"db_connection": {:hex, :db_connection, "2.3.1", "4c9f3ed1ef37471cbdd2762d6655be11e38193904d9c5c1c9389f1b891a3088e", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}], "hexpm", "abaab61780dde30301d840417890bd9f74131041afd02174cf4e10635b3a63f5"},
"decimal": {:hex, :decimal, "2.0.0", "a78296e617b0f5dd4c6caf57c714431347912ffb1d0842e998e9792b5642d697", [:mix], [], "hexpm", "34666e9c55dea81013e77d9d87370fe6cb6291d1ef32f46a1600230b1d44f577"},
"decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"},
"dialyxir": {:hex, :dialyxir, "1.0.0", "6a1fa629f7881a9f5aaf3a78f094b2a51a0357c843871b8bc98824e7342d00a5", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "aeb06588145fac14ca08d8061a142d52753dbc2cf7f0d00fc1013f53f8654654"},
"earmark": {:hex, :earmark, "1.4.5", "62ffd3bd7722fb7a7b1ecd2419ea0b458c356e7168c1f5d65caf09b4fbdd13c8", [:mix], [], "hexpm", "b7d0e6263d83dc27141a523467799a685965bf8b13b6743413f19a7079843f4f"},
"ecto": {:hex, :ecto, "3.5.5", "48219a991bb86daba6e38a1e64f8cea540cded58950ff38fbc8163e062281a07", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "98dd0e5e1de7f45beca6130d13116eae675db59adfa055fb79612406acf6f6f1"},
"ecto_sql": {:hex, :ecto_sql, "3.5.3", "1964df0305538364b97cc4661a2bd2b6c89d803e66e5655e4e55ff1571943efd", [:mix], [{:db_connection, "~> 2.2", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.5.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.3.0 or ~> 0.4.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d2f53592432ce17d3978feb8f43e8dc0705e288b0890caf06d449785f018061c"},
"ecto": {:hex, :ecto, "3.7.2", "44c034f88e1980754983cc4400585970b4206841f6f3780967a65a9150ef09a8", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a600da5772d1c31abbf06f3e4a1ffb150e74ed3e2aa92ff3cee95901657a874e"},
"ecto_sql": {:hex, :ecto_sql, "3.7.2", "55c60aa3a06168912abf145c6df38b0295c34118c3624cf7a6977cd6ce043081", [:mix], [{:db_connection, "~> 2.2", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.4.0 or ~> 0.5.0 or ~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0 or ~> 0.16.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3c218ea62f305dcaef0b915fb56583195e7b91c91dcfb006ba1f669bfacbff2a"},
"erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"},
"etso": {:hex, :etso, "0.1.1", "bfc5e30483d397774a64981fc93511d3ed0dcb9d19bc3cba03df7b9555a68636", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}], "hexpm", "194ed738ce2e1197b326071b42dc092d6df4a7492575a63d6b797a0c59727088"},
"ex_doc": {:hex, :ex_doc, "0.22.1", "9bb6d51508778193a4ea90fa16eac47f8b67934f33f8271d5e1edec2dc0eee4c", [:mix], [{:earmark, "~> 1.4.0", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm", "d957de1b75cb9f78d3ee17820733dc4460114d8b1e11f7ee4fd6546e69b1db60"},
"makeup": {:hex, :makeup, "1.0.3", "e339e2f766d12e7260e6672dd4047405963c5ec99661abdc432e6ec67d29ef95", [:mix], [{:nimble_parsec, "~> 0.5", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "2e9b4996d11832947731f7608fed7ad2f9443011b3b479ae288011265cdd3dad"},
"makeup_elixir": {:hex, :makeup_elixir, "0.14.1", "4f0e96847c63c17841d42c08107405a005a2680eb9c7ccadfd757bd31dabccfb", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "f2438b1a80eaec9ede832b5c41cd4f373b38fd7aa33e3b22d9db79e640cbde11"},
"nimble_parsec": {:hex, :nimble_parsec, "0.6.0", "32111b3bf39137144abd7ba1cce0914533b2d16ef35e8abc5ec8be6122944263", [:mix], [], "hexpm", "27eac315a94909d4dc68bc07a4a83e06c8379237c5ea528a9acff4ca1c873c52"},
"postgrex": {:hex, :postgrex, "0.15.7", "724410acd48abac529d0faa6c2a379fb8ae2088e31247687b16cacc0e0883372", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "88310c010ff047cecd73d5ceca1d99205e4b1ab1b9abfdab7e00f5c9d20ef8f9"},
"telemetry": {:hex, :telemetry, "0.4.2", "2808c992455e08d6177322f14d3bdb6b625fbcfd233a73505870d8738a2f4599", [:rebar3], [], "hexpm", "2d1419bd9dda6a206d7b5852179511722e2b18812310d304620c7bd92a13fcef"},
"telemetry": {:hex, :telemetry, "0.4.3", "a06428a514bdbc63293cd9a6263aad00ddeb66f608163bdec7c8995784080818", [:rebar3], [], "hexpm", "eb72b8365ffda5bed68a620d1da88525e326cb82a75ee61354fc24b844768041"},
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ defmodule Fsmx.Repo.Migrations.CreateTestSchemas do
create table(:test) do
add :state, :string
add :before, :string
add :other_state, :string
end
end
end
Loading

0 comments on commit 0a3c07f

Please sign in to comment.