Current section
Files
Jump to
Current section
Files
lib/as_fsm.ex
defmodule AsFsm do
@moduledoc """
Implement Finite state machine in elixir
## Usage
**First you to define FSM module**
```elixir
defmodule TaskFsm do
use AsFsm, repo: MyApp.Repo
# by default state is check from column `state` of struct
# you can specify your own with
# use AsFsm, repo: MyApp.Repo, column: :status
# define your event
defevent(:start, from: :idle, to: :running)
defevent(:pause, from: :running, to: :paused)
defevent(:stop, from: [:running, :paused], to: :idle)
# you can define some hook
# it is automatically invoked if defined
def before_start(context) do
# do something then return context
context
end
def on_start(context) do
# do something then return context
context
end
end
```
All appropriate event function will be generated. In this example we have
```elixir
def start(context), do: ....
def paus(context), do: ....
def stop(context), do: ....
```
**Then use it**
- Trigger an even transition
```elixir
my_task
|> TaskFsm.new_context(other_params)
|> TaskFsm.start()
```
- Or trigger by name
```elixir
my_task
|> TaskFsm.new_context(other_params)
|> TaskFsm.trigger(:start)
```
## Understand the context
```elixir
@type :: %Context{
struct: struct(),
state: any(),
valid?: boolean(),
error: String.t() | nil,
multi: Ecto.Multi.t() | nil
}
```
- `struct` is your data
- `state` any data you want to pass to transition, it could be parameter from client
- `valid?` if it is true, then data will be persisted
- `error` error message in case `valid?` is false
- `multi` is an `Ecto.Multi` you can pass a multi to `new_context()`, it make sure all action you do in a transaction
## Event hook
For each event you can define 2 hook
- `before_hook` you can define this hook to check for some condition before doing transation
- `on_hook` this is your hook to do some logic on transaction
These 2 hooks must return a context. If you want to stop this transition, set `valid?` to false and return the context.
## Custom persist struct
You can define your own function to persist struct state. This function is run within Multi so that it must return `{:ok, data} | {:error, reason}`
```elixr
def persist(struct, new_state, _context) do
# do your update logic
# or write log here
end
```
"""
defmodule Event do
defstruct key: nil, name: nil, from: [], to: nil
end
defmodule Context do
defstruct [:struct, :state, valid?: true, error: nil, multi: nil]
def new(attr) when is_list(attr) do
attr = Enum.reject(attr, fn {_k, v} -> is_nil(v) end)
%Context{multi: Ecto.Multi.new()}
|> struct(attr)
end
def new(struct) do
%Context{struct: struct, multi: Ecto.Multi.new()}
end
end
@type event :: %Event{
key: atom(),
name: String.t(),
from: :atom | list(),
to: :atom
}
@type context :: %Context{
struct: struct(),
state: any(),
valid?: boolean(),
error: String.t() | nil,
multi: Ecto.Multi.t() | nil
}
defmacro __using__(opts) do
column = opts[:column] || :state
repo = opts[:repo] || raise "Repo is missing"
quote location: :keep do
import AsFsm
@repo unquote(repo)
@column unquote(column)
Module.register_attribute(__MODULE__, :events, accumulate: true)
@before_compile AsFsm
def persist(struct, state, _context) do
Ecto.Changeset.changeset(struct, [{@column, state}])
|> @repo.update
end
defoverridable persist: 3
end
end
defmacro defevent(event_id, opts \\ []) do
from = Keyword.get(opts, :from)
to = Keyword.get(opts, :to)
name = Keyword.get(opts, :name)
name = Macro.expand(name, __CALLER__)
if is_nil(from) do
raise ArgumentError, message: ":from options is required"
end
if is_nil(to) do
raise ArgumentError, message: ":to options is required"
end
from = if is_atom(from), do: [from], else: from
quote location: :keep do
@events %Event{
key: unquote(event_id),
from: unquote(from),
to: unquote(to),
name: unquote(name)
}
def unquote(event_id)(context) do
trigger(unquote(event_id), context)
end
end
end
@doc """
List all events
"""
@callback list_events() :: [event]
@doc """
List all available events for given state
"""
@callback list_events(state :: atom()) :: [event]
@doc """
Get event object for given event name
"""
@callback get_event(atom()) :: {:ok, event} | {:error, :event_undefined}
@doc """
Check if given event can be trigger with given state
"""
@callback can(event_id :: atom(), current_state :: atom()) ::
:ok | {:error, :event_undefined} | {:error, :invalid_state}
@doc """
Check if there is any transition from state_a to state_b
"""
@callback has_transition?(from_state :: atom(), to_state :: atom()) :: boolean()
@doc """
Create new context
```elixir
# new context without state data
new_context(struct)
# new context with data
new_context(struct, params)
# pass existing multi
new_context(struct, params, existing_multi)
```
"""
@callback new_context(struct(), state :: any(), multi :: Ecto.Multi.t()) :: context
@doc """
Trigger event by event name
```elixir
MyFsm.new_context(my_order, %{user: user})
|> MyFsm.trigger(:deliver)
```
"""
@callback trigger(context(), atom()) :: {:ok, map()} | {:error, map()}
defmacro __before_compile__(_env) do
quote location: :keep do
def list_events() do
Enum.map(@events, & &1.key)
end
def list_events(from_state) do
Enum.filter(@events, &(from_state in &1.from))
|> Enum.map(& &1.key)
end
def get_event(key) do
event = Enum.find(@events, &(&1.key == key))
if event do
{:ok, event}
else
{:error, :event_undefined}
end
end
def can(action, current_state) do
event = Enum.find(@events, &(&1.key == action))
with {:event, false} <- {:event, is_nil(event)},
{:state, true} <- {:state, current_state in event.from} do
:ok
else
{:event, _} -> {:error, :event_undefined}
{:state, _} -> {:error, :invalid_state}
end
end
def has_transition?(from_state, to_state) do
Enum.find_value(@events, false, fn event ->
from_state in event.from and to_state == event.to
end)
end
def new_context(struct, state \\ nil, multi \\ nil) do
Context.new(struct: struct, state: state, multi: multi)
end
def trigger(%Context{} = context, event_id) do
event = get_event(event_id)
before_hook = :"before_#{event_id}"
transition_hook = :"on_#{event_id}"
with {:ok, event} <- get_event(event_id),
{:ok, context} <- apply_hook(context, before_hook),
{:ok, context} <- apply_hook(context, transition_hook) do
final_multi(context, event.to)
else
err ->
err
end
end
defp apply_hook(context, hook_name) do
if :erlang.function_exported(__MODULE__, hook_name, 1) do
apply(__MODULE__, hook_name, [context])
else
{:ok, context}
end
end
defp final_multi(%{valid?: true, multi: multi} = context, to_state) do
Ecto.Multi.run(multi, :persist, fn _, _changes ->
persist(context.struct, to_state, context)
end)
|> @repo.transaction()
end
end
end
end