Skip to content

Commit

Permalink
Adding a readme
Browse files Browse the repository at this point in the history
  • Loading branch information
naps62 committed Jun 24, 2020
1 parent 4ebd476 commit fb05a1d
Show file tree
Hide file tree
Showing 22 changed files with 594 additions and 121 deletions.
1 change: 1 addition & 0 deletions .formatter.exs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# Used by "mix format"
[
import_deps: [:ecto, :ecto_sql],
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
]
221 changes: 218 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,29 @@
# Fsmx

A Finite-state machine implementation in Elixir, with opt-in Ecto friendliness
[ecto-multi]: https://hexdocs.pm/ecto/Ecto.Multi.html
[bamboo]: https://github.com/thoughtbot/bamboo
[sage]: https://github.com/Nebo15/sage

A Finite-state machine implementation in Elixir, with opt-in Ecto friendliness.

Highlights:
* Plays nicely with both bare Elixir structs and Ecto changesets
* Ability to wrap transitions inside an Ecto.Multi for atomic updates
* Guides you in the right direction when it comes to [side effects][a-note-on-side-effects]

---

* [Installation](#installation)
* [Usage](#usage)
* [Simple state machine](#simple-state-machine)
* [Callbacks before transitions](#callbacks-before-transitions)
* [Validating transitions](#validating-transitions)
* [Decoupling logic from data](#decoupling-logic-from-data)
* [Ecto support](#ecto-support)
* [Transition changesets](#transition-changesets)
* [Transition with Ecto.Multi](#transition-with-ecto-multi)
* [A note on side effects](#a-note-on-side-effects)


## Installation

Expand All @@ -16,11 +39,203 @@ end

## Usage

TODO
### Simple state machine

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

use Fsmx, transitions: %{
"one" => ["two", "three"],
"two" => ["three", "four"],
"three" => "four"
}
end
```

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

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

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

Fsmx.transition(struct, "four")
# {:error, "invalid transition from one to four"}
```


### Callbacks before transitions

You can implement a `before_transition/3` callback to mutate the struct when before a transition happens.
You only need to pattern-match on the scenarios you want to catch. No need to add a catch-all/do-nothing function at the
end (the library already does that for you).

```elixir
defmodule App.StateMachine do
# ...

def before_transition(struct, "two", _destination_state) do
{:ok, %{struct | data: %{foo: :bar}}}
end
end
```

Usage:

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

Fsmx.transition(struct, "three")
# {:ok, %App.StateMachine{state: "three", data: %{foo: :bar}}
```


### Validating transitions

The same `before_transition/3` callback can be used to add custom validation logic, by returning an `{:error, _}` tuple
when needed:

```elixir
defmodule App.StateMachine do
# ...


def before_transition(%{data: nil}, _initial_state, "four") do
{:error, "cannot reacth state four without data"}
end
end
```

Usage:

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

Fsmx.transition(struct, "four")
# {:error, "cannot react state four without data"}
```

## Ecto support

TODO
Support for Ecto is built in, as long as `ecto` is in your `mix.exs` dependencies. With it, you get the ability to
define state machines using Ecto schemas, and the `Fsmx.Ecto` module:

```elixir
defmodule App.StateMachineSchema do
use Ecto.Schema

schema "state_machine" do
field :state, :string, default: "one"
field :data, :map
end

use Fsmx, transitions: %{
"one" => ["two", "three"],
"two" => ["three", "four"],
"three" => "four"
}
end
```

You can then mutate your state machine in one of two ways:

### 1. Transition changesets

Returns a changeset that mutates the `:state` field (or `{:error, _}` if the transition is invalid).

```elixir
{:ok, schema} = %App.StateMachineSchema{state: "one"} |> Repo.insert()

Fsmx.transition_changeset(schema, "two")
# #Ecto.Changeset<changes: %{state: "two"}>
```

You can customize the changeset function, and again pattern match on specific transitions, and additional params:

```elixir
defmodule App.StateMachineSchema do
# ...

# only include sent data on transitions from "one" to "two"
def transition_changeset(changeset, "one", "two", params) do
# changeset already includes a :state field change
changeset
|> cast(params, [:data])
|> validate_required([:data])
end
```

Usage:

```elixir
{:ok, schema} = %App.StateMachineSchema{state: "one"} |> Repo.insert()

Fsmx.transition_changeset(schema, "two", %{"data"=> %{foo: :bar}})
# #Ecto.Changeset<changes: %{state: "two", data: %{foo: :bar}>
```

### 2. Transition with Ecto.Multi

**Note: Please read [a note on side effects](#a-note-on-side-effects) first. Your future self will thank you.**

If a state transition is part of a larger operation, and you want to guarantee atomicity of the whole operation, you can
plug a state transition into an [`Ecto.Multi`][ecto-multi]. The same changeset seen above will be used here:

```elixir
{:ok, schema} = %App.StateMachineSchema{state: "one"} |> Repo.insert()

Ecto.Multi.new()
|> Fsmx.transition_multi(schema, "transition-id", "two", %{"data" => %{foo: :bar}})
|> Repo.transaction()
```

When using `Ecto.Multi`, you also get an additional `after_transition_multi/3` callback, where you can append additional
operations the resulting transaction, such as dealing with side effects (but again, please no that [side effects are
tricky](#a-note-on-side-effects))

```elixir
defmodule App.StateMachineSchema do
def after_transition_multi(schema, _from, "four") do
Mailer.notify_admin(schema)
|> Bamboo.deliver_later()

{:ok, nil}
end
end
```

Note that `after_transition_multi/3` callbacks still run inside the database transaction, so be careful with expensive
operations. In this example `Bamboo.deliver_later/1` (from the awesome [Bamboo][bamboo] package) doesn't spend time sending the actual email, it just spawns a task to do it asynchronously.

## A note on side effects

Side effects are tricky. Database transactions are meant to guarantee atomicity, but side effects often touch beyond the
database. Sending emails when a task is complete is a straight-forward example.

When you run side effects within an `Ecto.Multi` you need to be aware that, should the transaction later be rolled
back, there's no way to un-send that email.

If the side effect is the last operation within your `Ecto.Multi`, you're probably 99% fine, which works for a lot of cases.
But if you have more complex transactions, or if you do need 99.9999% consistency guarantees (because, let's face
it, 100% is a pipe dream), then this simple library might not be for you.

Consider looking at [`Sage`][sage], for instance.


```elixir
# this is *probably* fine
Ecto.Multi.new()
|> Fsmx.transition_multi(schema, "transition-id", "two", %{"data" => %{foo: :bar}})
|> Repo.transaction()

# this is dangerous, because your transition callback will run before the whole database transaction has run
Ecto.Multi.new()
|> Fsmx.transition_multi(schema, "transition-id", "two", %{"data" => %{foo: :bar}})
|> Ecto.Multi.update(:update, a_very_unreliable_changeset())
|> Repo.transaction()
```

# About

Expand Down
3 changes: 3 additions & 0 deletions config/config.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
use Mix.Config

import_config "#{Mix.env()}.exs"
1 change: 1 addition & 0 deletions config/dev.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
use Mix.Config
1 change: 1 addition & 0 deletions config/prod.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
use Mix.Config
13 changes: 13 additions & 0 deletions config/test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
use Mix.Config

config :fsmx, ecto_repos: [Fsmx.Repo]

config :postgrex, Fsmx.Repo,
adapter: Ecto.Adapters.Postgres,
username: System.get_env("POSTGRES_USER", "postgres"),
password: System.get_env("POSTGRES_PASS", "postgres"),
hostname: System.get_env("POSTGRES_HOST", "localhost"),
database: "fsmx_test",
pool: Ecto.Adapters.SQL.Sandbox

config :logger, level: :warn
44 changes: 33 additions & 11 deletions lib/fsmx.ex
Original file line number Diff line number Diff line change
@@ -1,20 +1,42 @@
defmodule Fsmx do
@type state_t :: binary
def transition(struct, new_state) do
with {:ok, struct} <- before_transition(struct, new_state) do
{:ok, %{struct | state: new_state}}
end
end

def transition(%mod{state: state} = struct, new_state) do
fsm = mod.__fsmx__()
transitions = fsm.__fsmx__(:transitions)
if Code.ensure_loaded?(Ecto) do
def transition_changeset(%mod{state: state} = schema, new_state, params \\ %{}) do
fsm = mod.__fsmx__()

with :ok <- validate_transition(state, new_state, transitions),
{:ok, struct} <- fsm.before_transition(struct, state, new_state),
{:ok, struct} <- do_transition(struct, new_state),
{:ok, struct} <- fsm.after_transition(struct, state, new_state) do
{:ok, struct}
with {:ok, schema} <- before_transition(schema, new_state, params) do
schema
|> Ecto.Changeset.change()
|> Ecto.Changeset.put_change(:state, new_state)
|> fsm.transition_changeset(state, new_state, params)
end
end

def transition_multi(multi, %mod{state: state} = schema, id, new_state, params \\ %{}) do
fsm = mod.__fsmx__()

with {:ok, changeset} <- transition_changeset(schema, new_state, params) do
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)
end)
end
end
end

defp do_transition(struct, new_state) do
{:ok, %{struct | state: new_state}}
defp before_transition(%mod{state: state} = struct, new_state, params \\ %{}) do
fsm = mod.__fsmx__()
transitions = fsm.__fsmx__(:transitions)

with :ok <- validate_transition(state, new_state, transitions) do
fsm.before_transition(struct, state, new_state)
end
end

defp validate_transition(state, new_state, transitions) do
Expand Down
3 changes: 2 additions & 1 deletion lib/fsmx/fsm.ex
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ defmodule Fsmx.Fsm do
defmacro __before_compile__(_env) do
quote generated: false do
def before_transition(struct, _from, _to), do: {:ok, struct}
def after_transition(struct, _from, _to), do: {:ok, struct}
def transition_changeset(changeset, _from, _to, _params), do: {:ok, changeset}
def after_transition_multi(struct, _from, _to), do: {:ok, struct}
end
end
end
15 changes: 11 additions & 4 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,28 @@ defmodule Fsmx.MixProject do
app: :fsmx,
version: "0.1.0",
elixir: "~> 1.9",
applications: applications(Mix.env()),
elixirc_paths: elixirc_paths(Mix.env()),
start_permanent: Mix.env() == :prod,
deps: deps()
]
end

# Run "mix help compile.app" to learn about applications.

def application do
[
extra_applications: [:logger]
]
[extra_applications: applications(Mix.env())]
end

defp applications(:test), do: [:logger, :ecto]
defp applications(_), do: [:logger]

defp deps do
[]
[
{:postgrex, ">= 0.0.0", only: :test},
{:ecto, ">= 3.0.0", optional: true},
{:ecto_sql, ">= 3.0.0", optional: true}
]
end

defp elixirc_paths(:test), do: ["lib", "test/support"]
Expand Down
10 changes: 10 additions & 0 deletions mix.lock
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
%{
"connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], [], "hexpm", "4a0850c9be22a43af9920a71ab17c051f5f7d45c209e40269a1938832510e4d9"},
"db_connection": {:hex, :db_connection, "2.2.2", "3bbca41b199e1598245b716248964926303b5d4609ff065125ce98bcd368939e", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}], "hexpm", "642af240d8a8affb93b4ba5a6fcd2bbcbdc327e1a524b825d383711536f8070c"},
"decimal": {:hex, :decimal, "1.8.1", "a4ef3f5f3428bdbc0d35374029ffcf4ede8533536fa79896dd450168d9acdf3c", [:mix], [], "hexpm", "3cb154b00225ac687f6cbd4acc4b7960027c757a5152b369923ead9ddbca7aec"},
"ecto": {:hex, :ecto, "3.4.5", "2bcd262f57b2c888b0bd7f7a28c8a48aa11dc1a2c6a858e45dd8f8426d504265", [: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", "8c6d1d4d524559e9b7a062f0498e2c206122552d63eacff0a6567ffe7a8e8691"},
"ecto_sql": {:hex, :ecto_sql, "3.4.4", "d28bac2d420f708993baed522054870086fd45016a9d09bb2cd521b9c48d32ea", [:mix], [{:db_connection, "~> 2.2", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.4.3", [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.0", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "edb49af715dd72f213b66adfd0f668a43c17ed510b5d9ac7528569b23af57fe8"},
"etso": {:hex, :etso, "0.1.1", "bfc5e30483d397774a64981fc93511d3ed0dcb9d19bc3cba03df7b9555a68636", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}], "hexpm", "194ed738ce2e1197b326071b42dc092d6df4a7492575a63d6b797a0c59727088"},
"postgrex": {:hex, :postgrex, "0.15.5", "aec40306a622d459b01bff890fa42f1430dac61593b122754144ad9033a2152f", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "ed90c81e1525f65a2ba2279dbcebf030d6d13328daa2f8088b9661eb9143af7f"},
"telemetry": {:hex, :telemetry, "0.4.2", "2808c992455e08d6177322f14d3bdb6b625fbcfd233a73505870d8738a2f4599", [:rebar3], [], "hexpm", "2d1419bd9dda6a206d7b5852179511722e2b18812310d304620c7bd92a13fcef"},
}
10 changes: 10 additions & 0 deletions priv/repo/migrations/20200624134635_create_test_schemas.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
defmodule Fsmx.Repo.Migrations.CreateTestSchemas do
use Ecto.Migration

def change do
create table(:test) do
add :state, :string
add :before, :string
end
end
end
Loading

0 comments on commit fb05a1d

Please sign in to comment.