Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .tool-versions
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
elixir 1.13.1-otp-24
erlang 26.2.5.15
elixir 1.19.1-otp-26
140 changes: 74 additions & 66 deletions lib/gearbox.ex
Original file line number Diff line number Diff line change
Expand Up @@ -52,13 +52,18 @@ defmodule Gearbox do
Therefore, Gearbox nudges you to keep domain/business-logic callbacks close to your contexts/domain events.
Gearbox still ships with a `guard_transition/3` callback, as that is intrinsic to state machines.

## Options
- `:field` - used to retrieve the state of the given struct. Defaults to `:state`
- `:states` - list of finite states in the state machine
- `:initial` - initial state of the struct, if struct has `nil` state to begin with.
Defaults to the first item of `:states`
- `:transitions` - a map of possible transitions from `current_state` to `next_state`.
`*` wildcard is allowed to indicate any states.
## Defining a machine

To create a state machine, you need to define a module that implements the `Gearbox.Machine` behaviour.
This behaviour requires you to implement a few callbacks to define your state machine.

### Callbacks

- `field/0` - The field in your struct that holds the state.
- `states/0` - A list of all possible states.
- `initial_state/0` - The initial state of the machine.
- `transitions/0` - A map of allowed transitions. `*` wildcard is allowed to indicate any states.
- `guard_transition/3` (optional) - A callback to guard transitions.

## Example

Expand All @@ -67,14 +72,17 @@ defmodule Gearbox do
end

defmodule Gearbox.OrderMachine do
use Gearbox,
field: :status,
states: ~w(pending_payment cancelled paid pending_collection refunded fulfilled),
initial: "pending_payment",
transitions: %{
@behaviour Gearbox.Machine

def field, do: :status
def states, do: ~w(pending_payment cancelled paid pending_collection refunded fulfilled)
def initial_state, do: "pending_payment"
def transitions do
%{
"pending_payment" => ~w(cancelled paid),
"paid" => ~w(pending_collection refunded),
}
end
end

iex> alias Gearbox.Order # Your struct
Expand All @@ -96,75 +104,67 @@ defmodule Gearbox do

@type state() :: atom | String.t()

@doc """
Add guard conditions before transitioning.
@wildcard "*"

The function receives struct as the first argument, the current state as
the second argument, and the desired state as the last argument.
defmodule Machine do
@moduledoc """
The behaviour for a Gearbox state machine.

You can guard on both `from` and `to` states, e.g:
State machine modules must implement this behaviour to be used with `Gearbox`.
"""

* Every time %Order{} transits out of `pending`, do X
* Every time %Order{} transits into `paid`, do Y
@type state() :: atom | String.t()

If this function returns a `{:halt, reason}`, execution of the transition will halt.
Any other things will allow the transition to go through.
@doc "The field in the struct that holds the state."
@callback field() :: atom

> Note: This hook only gets triggered if the transition is valid.
"""
@callback guard_transition(struct :: any, from :: state(), to :: state()) :: {:halt, any} | any
@doc "The list of all possible states."
@callback states() :: list(state())

@wildcard "*"
@doc "The initial state."
@callback initial_state() :: state()

defmodule InvalidTransitionError do
@moduledoc """
This error is raised when you use `Gearbox.transition!/3`.
@doc "The map of transitions."
@callback transitions() :: map()

For a non-error raising variant, see `Gearbox.transition/3`
"""
defexception message: "State transition is not allowed."
end
@doc """
Add guard conditions before transitioning.

@doc false
defmacro __using__(opts) do
field = Keyword.get(opts, :field, :state)
states = Keyword.get(opts, :states)
initial = Keyword.get(opts, :initial)
transitions = Keyword.get(opts, :transitions)
The function receives struct as the first argument, the current state as
the second argument, and the desired state as the last argument.

quote bind_quoted: [
field: field,
states: states,
initial: initial,
transitions: transitions
] do
@behaviour Gearbox
You can guard on both `from` and `to` states, e.g:

@doc false
def __machine_field__(), do: unquote(field)
* Every time %Order{} transits out of `pending`, do X
* Every time %Order{} transits into `paid`, do Y

@doc false
def __machine_states__(:initial), do: unquote(initial || List.first(states))
If this function returns a `{:halt, reason}`, execution of the transition will halt.
Any other things will allow the transition to go through.

@doc false
def __machine_states__(), do: unquote(states)
> Note: This hook only gets triggered if the transition is valid.
"""
@callback guard_transition(struct :: any, from :: state(), to :: state()) ::
{:halt, any} | any

@doc false
def __machine_transitions__(), do: unquote(Macro.escape(transitions))
@optional_callbacks guard_transition: 3
end

@doc false
def guard_transition(struct, from, to), do: struct
defmodule InvalidTransitionError do
@moduledoc """
This error is raised when you use `Gearbox.transition!/3`.

defoverridable guard_transition: 3
end
For a non-error raising variant, see `Gearbox.transition/3`
"""
defexception message: "State transition is not allowed."
end

@doc """
Transition a struct or map to a given state. If transition is invalid, an `InvalidTransitionError` exception is raised.

Uses `Gearbox.transition/3` under the hood.
"""
@spec transition!(struct :: struct | map, machine :: any, next_state :: state()) :: struct | map
@spec transition!(struct :: struct | map, machine :: module, next_state :: state()) ::
struct | map
def transition!(struct, machine, next_state) do
case transition(struct, machine, next_state) do
{:error, msg} ->
Expand All @@ -185,12 +185,12 @@ defmodule Gearbox do

Returns an `{:ok, updated_struct_or_map}` or `{:error, message}` tuple.
"""
@spec transition(struct :: struct | map, machine :: any, next_state :: state()) ::
@spec transition(struct :: struct | map, machine :: module, next_state :: state()) ::
{:ok, struct | map} | {:error, String.t()}
def transition(struct, machine, next_state) do
case validate_transition(struct, machine, next_state) do
{:ok, nil} ->
struct = Map.put(struct, machine.__machine_field__(), next_state)
struct = Map.put(struct, machine.field(), next_state)
{:ok, struct}

{:error, reason} ->
Expand All @@ -206,20 +206,20 @@ defmodule Gearbox do
- `{:ok, nil}` if it can transition or,
- `{:error, reason}` if transition cannot be made.
"""
@spec validate_transition(struct :: struct | map, machine :: any, next_state :: state()) ::
@spec validate_transition(struct :: struct | map, machine :: module, next_state :: state()) ::
{:ok, any} | {:error, String.t()}
def validate_transition(struct, machine, next_state) do
field = machine.__machine_field__()
states = machine.__machine_states__()
initial_state = machine.__machine_states__(:initial)
field = machine.field()
states = machine.states()
initial_state = machine.initial_state()
current_state = Map.get(struct, field) || initial_state
transitions = machine.__machine_transitions__()
transitions = machine.transitions()

with candidates <- Map.take(transitions, [current_state, @wildcard]),
possible_transitions = get_possible_transitions(candidates, states),
true <- next_state in possible_transitions,
condition when is_guard_allowed?(condition) <-
machine.guard_transition(struct, current_state, next_state) do
maybe_guard_transition(machine, struct, current_state, next_state) do
{:ok, nil}
else
false ->
Expand All @@ -232,6 +232,14 @@ defmodule Gearbox do
end
end

defp maybe_guard_transition(machine, struct, from, to) do
if function_exported?(machine, :guard_transition, 3) do
machine.guard_transition(struct, from, to)
else
struct
end
end

@spec get_possible_transitions(candidates :: map(), states :: list(state())) :: list()
defp get_possible_transitions(candidates, states) do
Enum.reduce(candidates, [], fn {_k, destination}, acc ->
Expand Down
12 changes: 7 additions & 5 deletions lib/gearbox/ecto.ex
Original file line number Diff line number Diff line change
Expand Up @@ -15,22 +15,24 @@ if Code.ensure_loaded?(Ecto) do
- `{:ok, changeset}` with the transitioned field if the transition can be made
- `{:error, error_changeset}` with an error populated if transition cannot be made.
"""
@spec transition_changeset(struct :: struct, machine :: any, next_state :: Gearbox.state()) ::
{:ok, struct | map} | {:error, Ecto.Changeset.t()}
@spec transition_changeset(
struct :: Ecto.Schema.t() | Ecto.Changeset.t(),
machine :: module(),
next_state :: Gearbox.state()
) :: {:ok, Ecto.Changeset.t()} | {:error, Ecto.Changeset.t()}
def transition_changeset(struct, machine, next_state) do
validation_struct = maybe_apply_changeset_changes(struct)

case validate_transition(validation_struct, machine, next_state) do
{:ok, nil} ->
changeset = Ecto.Changeset.change(struct, %{machine.__machine_field__() => next_state})
changeset = Ecto.Changeset.change(struct, %{machine.field() => next_state})
{:ok, changeset}

{:error, reason} ->
error_changeset =
struct
|> struct()
|> Ecto.Changeset.change()
|> Ecto.Changeset.add_error(machine.__machine_field__(), reason)
|> Ecto.Changeset.add_error(machine.field(), reason)

{:error, error_changeset}
end
Expand Down
4 changes: 2 additions & 2 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,9 @@ defmodule Gearbox.MixProject do
# Run "mix help deps" to learn about dependencies.
defp deps do
[
{:ex_doc, "~> 0.28.0", only: :dev},
{:ex_doc, "~> 0.39.1", only: :dev},
{:earmark, "~> 1.4", only: :dev},
{:ecto, "~> 3.4", optional: true}
{:ecto, "~> 3.13", optional: true}
]
end

Expand Down
20 changes: 10 additions & 10 deletions mix.lock
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
%{
"decimal": {:hex, :decimal, "2.0.0", "a78296e617b0f5dd4c6caf57c714431347912ffb1d0842e998e9792b5642d697", [:mix], [], "hexpm", "34666e9c55dea81013e77d9d87370fe6cb6291d1ef32f46a1600230b1d44f577"},
"earmark": {:hex, :earmark, "1.4.27", "b413b0379043df51475a9b22ce344e8a58a117516c735b8871e6cdd5ed0f0153", [:mix], [{:earmark_parser, "~> 1.4.26", [hex: :earmark_parser, repo: "hexpm", optional: false]}], "hexpm", "579ebe2eaf4c7e040815a73a268036bcd96e6aab8ad2b1fcd979aaeb1ea47e15"},
"earmark_parser": {:hex, :earmark_parser, "1.4.26", "f4291134583f373c7d8755566122908eb9662df4c4b63caa66a0eabe06569b0a", [:mix], [], "hexpm", "48d460899f8a0c52c5470676611c01f64f3337bad0b26ddab43648428d94aabc"},
"ecto": {:hex, :ecto, "3.5.0", "9b45303af8e7eea81c0ad6fbcf2d442edb3f1c535a32ca42e3b1f31091a8995e", [: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", "9413a21b0b1a8256724545550832605918ab26632010bd47ce9d71d24f4f4bd1"},
"ex_doc": {:hex, :ex_doc, "0.28.4", "001a0ea6beac2f810f1abc3dbf4b123e9593eaa5f00dd13ded024eae7c523298", [:mix], [{:earmark_parser, "~> 1.4.19", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "bf85d003dd34911d89c8ddb8bda1a958af3471a274a4c2150a9c01c78ac3f8ed"},
"makeup": {:hex, :makeup, "1.1.0", "6b67c8bc2882a6b6a445859952a602afc1a41c2e08379ca057c0f525366fc3ca", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "0a45ed501f4a8897f580eabf99a2e5234ea3e75a4373c8a52824f6e873be57a6"},
"makeup_elixir": {:hex, :makeup_elixir, "0.16.0", "f8c570a0d33f8039513fbccaf7108c5d750f47d8defd44088371191b76492b0b", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "28b2cbdc13960a46ae9a8858c4bebdec3c9a6d7b4b9e7f4ed1502f8159f338e7"},
"makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"},
"nimble_parsec": {:hex, :nimble_parsec, "1.2.3", "244836e6e3f1200c7f30cb56733fd808744eca61fd182f731eac4af635cc6d0b", [:mix], [], "hexpm", "c8d789e39b9131acf7b99291e93dae60ab48ef14a7ee9d58c6964f59efb570b0"},
"telemetry": {:hex, :telemetry, "0.4.2", "2808c992455e08d6177322f14d3bdb6b625fbcfd233a73505870d8738a2f4599", [:rebar3], [], "hexpm", "2d1419bd9dda6a206d7b5852179511722e2b18812310d304620c7bd92a13fcef"},
"decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
"earmark": {:hex, :earmark, "1.4.48", "5f41e579d85ef812351211842b6e005f6e0cef111216dea7d4b9d58af4608434", [:mix], [], "hexpm", "a461a0ddfdc5432381c876af1c86c411fd78a25790c75023c7a4c035fdc858f9"},
"earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"},
"ecto": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 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", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"},
"ex_doc": {:hex, :ex_doc, "0.39.1", "e19d356a1ba1e8f8cfc79ce1c3f83884b6abfcb79329d435d4bbb3e97ccc286e", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "8abf0ed3e3ca87c0847dfc4168ceab5bedfe881692f1b7c45f4a11b232806865"},
"makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"},
"makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"},
"makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"},
"nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
"telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"},
}
57 changes: 32 additions & 25 deletions test/gearbox_ecto_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@ defmodule GearboxTest.Ecto do
use ExUnit.Case
doctest Gearbox.Ecto

alias GearboxTest.Ecto.GearboxMachine

defmodule GearSchema do
use Ecto.Schema

Expand All @@ -18,11 +16,12 @@ defmodule GearboxTest.Ecto do
gear = %GearSchema{state: "neutral"}

defmodule GearboxMachine do
use Gearbox,
states: ~w(neutral drive),
transitions: %{
"neutral" => ~w(drive)
}
@behaviour Gearbox.Machine

def field, do: :state
def states, do: ~w(neutral drive)
def initial_state, do: "neutral"
def transitions, do: %{"neutral" => ~w(drive)}
end

assert {:ok, %Ecto.Changeset{} = gear_changeset} =
Expand All @@ -38,11 +37,12 @@ defmodule GearboxTest.Ecto do
gear = %GearSchema{state: "neutral"}

defmodule GearboxMachine do
use Gearbox,
states: ~w(neutral drive parking),
transitions: %{
"neutral" => ~w(drive)
}
@behaviour Gearbox.Machine

def field, do: :state
def states, do: ~w(neutral drive parking)
def initial_state, do: "neutral"
def transitions, do: %{"neutral" => ~w(drive)}
end

assert {:error, %Ecto.Changeset{} = err_changeset} =
Expand All @@ -60,11 +60,12 @@ defmodule GearboxTest.Ecto do
gear = %GearSchema{state: "undefined"}

defmodule GearboxMachine do
use Gearbox,
states: ~w(neutral drive),
transitions: %{
"neutral" => ~w(drive)
}
@behaviour Gearbox.Machine

def field, do: :state
def states, do: ~w(neutral drive)
def initial_state, do: "neutral"
def transitions, do: %{"neutral" => ~w(drive)}
end

assert {:error, %Ecto.Changeset{} = err_changeset} =
Expand All @@ -82,11 +83,12 @@ defmodule GearboxTest.Ecto do
gear = %GearSchema{state: "neutral"}

defmodule GearboxMachine do
use Gearbox,
states: ~w(neutral drive),
transitions: %{
"neutral" => ~w(drive)
}
@behaviour Gearbox.Machine

def field, do: :state
def states, do: ~w(neutral drive)
def initial_state, do: "neutral"
def transitions, do: %{"neutral" => ~w(drive)}
end

assert {:error, %Ecto.Changeset{} = err_changeset} =
Expand All @@ -104,9 +106,14 @@ defmodule GearboxTest.Ecto do
gear = %GearSchema{state: "neutral"}

defmodule GearboxMachine do
use Gearbox,
states: ~w(neutral drive next),
transitions: %{
@behaviour Gearbox.Machine

def field, do: :state
def states, do: ~w(neutral drive next)
def initial_state, do: "neutral"

def transitions,
do: %{
"neutral" => ~w(drive),
"drive" => ~w(next)
}
Expand Down
Loading