diff --git a/.formatter.exs b/.formatter.exs index d2cda26..fe898bf 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -1,4 +1,5 @@ # Used by "mix format" [ + import_deps: [:ecto, :ecto_sql], inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] ] diff --git a/README.md b/README.md index 50d58cb..0407986 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 +``` + +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 +``` + +### 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 diff --git a/config/config.exs b/config/config.exs new file mode 100644 index 0000000..8233fe9 --- /dev/null +++ b/config/config.exs @@ -0,0 +1,3 @@ +use Mix.Config + +import_config "#{Mix.env()}.exs" diff --git a/config/dev.exs b/config/dev.exs new file mode 100644 index 0000000..d2d855e --- /dev/null +++ b/config/dev.exs @@ -0,0 +1 @@ +use Mix.Config diff --git a/config/prod.exs b/config/prod.exs new file mode 100644 index 0000000..d2d855e --- /dev/null +++ b/config/prod.exs @@ -0,0 +1 @@ +use Mix.Config diff --git a/config/test.exs b/config/test.exs new file mode 100644 index 0000000..7c416bd --- /dev/null +++ b/config/test.exs @@ -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 diff --git a/lib/fsmx.ex b/lib/fsmx.ex index c8d645c..3e0ba80 100644 --- a/lib/fsmx.ex +++ b/lib/fsmx.ex @@ -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 diff --git a/lib/fsmx/fsm.ex b/lib/fsmx/fsm.ex index 713c3d9..48e9e12 100644 --- a/lib/fsmx/fsm.ex +++ b/lib/fsmx/fsm.ex @@ -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 diff --git a/mix.exs b/mix.exs index 440e0ec..46e61f5 100644 --- a/mix.exs +++ b/mix.exs @@ -6,6 +6,7 @@ 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() @@ -13,14 +14,20 @@ defmodule Fsmx.MixProject do 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"] diff --git a/mix.lock b/mix.lock new file mode 100644 index 0000000..66114f9 --- /dev/null +++ b/mix.lock @@ -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"}, +} diff --git a/priv/repo/migrations/20200624134635_create_test_schemas.exs b/priv/repo/migrations/20200624134635_create_test_schemas.exs new file mode 100644 index 0000000..3a30d3a --- /dev/null +++ b/priv/repo/migrations/20200624134635_create_test_schemas.exs @@ -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 diff --git a/test/fsmx/ecto_test.exs b/test/fsmx/ecto_test.exs new file mode 100644 index 0000000..dbacd36 --- /dev/null +++ b/test/fsmx/ecto_test.exs @@ -0,0 +1,125 @@ +defmodule Fsmx.EctoTest do + use Fsmx.EctoCase + + alias Ecto.Multi + alias Fsmx.Repo + alias Fsmx.TestEctoSchemas.{Simple, WithCallbacks, WithSeparateFsm} + + describe "transition_changeset/2" do + test "returns a changeset" do + one = %Simple{state: "1"} + + {:ok, two_changeset} = Fsmx.transition_changeset(one, "2", []) + + assert %Ecto.Changeset{} = two_changeset + end + + test "does not change the state directly" do + one = %Simple{state: "1"} + + {:ok, two_changeset} = Fsmx.transition_changeset(one, "2", []) + + assert two_changeset.data.state == "1" + end + + test "includes a change of the state field" do + one = %Simple{state: "1"} + + {:ok, two_changeset} = Fsmx.transition_changeset(one, "2", []) + + assert Ecto.Changeset.get_change(two_changeset, :state) == "2" + end + end + + describe "transition/2 with callbacks" do + test "calls before_transition/2 on struct" do + one = %WithCallbacks.ValidBefore{state: "1", before: false} + + {:ok, two} = Fsmx.transition_changeset(one, "2") + + assert %WithCallbacks.ValidBefore{before: "1"} = two.data + end + + test "fails if before_transition/2 returns an error" do + one = %WithCallbacks.InvalidBefore{state: "1", before: false} + + {:error, :before_failed} = Fsmx.transition_changeset(one, "2") + end + end + + describe "transition/2 with separate fsm module" do + test "works just the same" do + one = %WithSeparateFsm{state: "1"} + + {:ok, two} = Fsmx.transition_changeset(one, "2") + + assert %WithSeparateFsm{before: "1"} = two.data + end + end + + describe "transition_multi/5" do + test "adds a transition changeset to the given multi" do + one = %Simple{state: "1"} + + multi = Fsmx.transition_multi(Multi.new(), one, "transition", "2") + + assert %Ecto.Multi{operations: operations} = multi + + assert [_, {"transition", {:changeset, two_changeset, []}}] = operations + assert %Ecto.Changeset{} = two_changeset + assert two_changeset.data.state == "1" + assert Ecto.Changeset.get_change(two_changeset, :state) == "2" + end + + test "adds a run callback to the given multi" do + one = %Simple{state: "1"} + + multi = + Multi.new() + |> Fsmx.transition_multi(one, "transition", "2") + + assert %Ecto.Multi{operations: operations} = multi + assert [{"transition-callback", {:run, _}}, _] = operations + end + + test "transitions the schema when running the multi" do + {:ok, schema} = %Simple{state: "1"} |> Repo.insert() + + Multi.new() + |> Fsmx.transition_multi(schema, "transition", "2") + |> Repo.transaction() + + updated_schema = Repo.get(Simple, schema.id) + + assert %Simple{state: "2"} = updated_schema + end + + test "calls after_transition_multi/3 callbacks" do + {:ok, schema} = %WithCallbacks.ValidAfterMulti{state: "1"} |> Repo.insert() + + Multi.new() + |> Fsmx.transition_multi(schema, "transition", "2") + |> Repo.transaction() + + updated_schema = Repo.get(WithCallbacks.ValidAfterMulti, schema.id) + + assert %WithCallbacks.ValidAfterMulti{state: "2"} = updated_schema + assert_receive :after_transition_multi_called + end + + test "transaction is rolled back if after_transition_multi/3 callback fails" do + {:ok, schema} = %WithCallbacks.InvalidAfterMulti{state: "1"} |> Repo.insert() + + result = + Multi.new() + |> Fsmx.transition_multi(schema, "transition", "2") + |> Repo.transaction() + + assert {:error, "transition-callback", :after_transition_multi_failed, _} = result + + updated_schema = Repo.get(WithCallbacks.InvalidAfterMulti, schema.id) + + assert %WithCallbacks.InvalidAfterMulti{state: "1"} = updated_schema + end + end +end diff --git a/test/fsmx/struct_test.exs b/test/fsmx/struct_test.exs index 962ebbd..8e7d967 100644 --- a/test/fsmx/struct_test.exs +++ b/test/fsmx/struct_test.exs @@ -1,5 +1,53 @@ defmodule Fsmx.StructTest do use ExUnit.Case - alias Fsmx.Struct + alias Fsmx.TestStructs.{Simple, WithCallbacks, WithSeparateFsm} + + describe "transition/2" do + test "can do simple transitions" do + one = %Simple{state: "1"} + + {:ok, two} = Fsmx.transition(one, "2") + + assert %Simple{state: "2"} = two + + {:ok, three} = Fsmx.transition(two, "3") + + assert %Simple{state: "3"} = three + end + + test "fails to perform invalid transitions" do + one = %Simple{state: "1"} + + assert {:error, msg} = Fsmx.transition(one, "3") + + assert msg == "invalid transition from 1 to 3" + end + end + + describe "transition/2 with before_callbacks" do + test "calls before_transition/2 on struct" do + one = %WithCallbacks.ValidBefore{state: "1", before: false} + + {:ok, two} = Fsmx.transition(one, "2") + + assert %WithCallbacks.ValidBefore{state: "2", before: "1"} = two + end + + test "fails to transition if before_transition/3 returns an error" do + one = %WithCallbacks.InvalidBefore{state: "1", before: false} + + {:error, :before_failed} = Fsmx.transition(one, "2") + end + end + + describe "transition/2 with separate fsm module" do + test "works just the same" do + one = %WithSeparateFsm{state: "1"} + + {:ok, two} = Fsmx.transition(one, "2") + + assert %WithSeparateFsm{state: "2", before: "1"} = two + end + end end diff --git a/test/fsmx_test.exs b/test/fsmx_test.exs deleted file mode 100644 index 598e25f..0000000 --- a/test/fsmx_test.exs +++ /dev/null @@ -1,70 +0,0 @@ -defmodule FsmxTest do - use ExUnit.Case - doctest Fsmx - - alias Fsmx.TestStructs.{Simple, WithCallbacks, WithSeparateFsm} - - describe "transition/2" do - test "can do simple transitions" do - one = %Simple{state: "1"} - - {:ok, two} = Fsmx.transition(one, "2") - - assert %Simple{state: "2"} = two - - {:ok, three} = Fsmx.transition(two, "3") - - assert %Simple{state: "3"} = three - end - - test "fails to perform invalid transitions" do - one = %Simple{state: "1"} - - assert {:error, msg} = Fsmx.transition(one, "3") - - assert msg == "invalid transition from 1 to 3" - end - end - - describe "transition/2 with before_callbacks" do - test "calls before_transition/2 on struct" do - one = %WithCallbacks.ValidBefore{state: "1", before: false, after: false} - - {:ok, two} = Fsmx.transition(one, "2") - - assert %WithCallbacks.ValidBefore{state: "2", before: true, after: false} = two - end - - test "fails to transition if before_transition/3 returns an error" do - one = %WithCallbacks.InvalidBefore{state: "1", before: false, after: false} - - {:error, :before_failed} = Fsmx.transition(one, "2") - end - end - - describe "transition/2 with after_callbacks" do - test "calls before_transition/2 on struct" do - one = %WithCallbacks.ValidAfter{state: "1", before: false, after: false} - - {:ok, two} = Fsmx.transition(one, "2") - - assert %WithCallbacks.ValidAfter{state: "2", before: false, after: true} = two - end - - test "fails to transition if before_transition/3 returns an error" do - one = %WithCallbacks.InvalidAfter{state: "1", before: false, after: false} - - {:error, :after_failed} = Fsmx.transition(one, "2") - end - end - - describe "transition/2 with separate fsm module" do - test "works just the same" do - one = %WithSeparateFsm{state: "1"} - - {:ok, two} = Fsmx.transition(one, "2") - - assert %WithSeparateFsm{state: "2", before: true, after: true} = two - end - end -end diff --git a/test/support/ecto_case.ex b/test/support/ecto_case.ex new file mode 100644 index 0000000..9ad6aa8 --- /dev/null +++ b/test/support/ecto_case.ex @@ -0,0 +1,13 @@ +defmodule Fsmx.EctoCase do + use ExUnit.CaseTemplate + + alias Ecto.Adapters.SQL.Sandbox + + setup tags do + :ok = Sandbox.checkout(Fsmx.Repo) + + unless tags[:async] do + Sandbox.mode(Fsmx.Repo, {:shared, self()}) + end + end +end diff --git a/test/support/repo.ex b/test/support/repo.ex new file mode 100644 index 0000000..76cc28e --- /dev/null +++ b/test/support/repo.ex @@ -0,0 +1,6 @@ +defmodule Fsmx.Repo do + use Ecto.Repo, + otp_app: :postgrex, + adapter: Ecto.Adapters.Postgres, + priv: "test/support/repo/migrations" +end diff --git a/test/support/test_ecto_schemas/simple.ex b/test/support/test_ecto_schemas/simple.ex new file mode 100644 index 0000000..bf0e973 --- /dev/null +++ b/test/support/test_ecto_schemas/simple.ex @@ -0,0 +1,9 @@ +defmodule Fsmx.TestEctoSchemas.Simple do + use Ecto.Schema + + schema "test" do + field :state, :string + end + + use Fsmx.Struct, transitions: %{"1" => "2"} +end diff --git a/test/support/test_ecto_schemas/with_callbacks.ex b/test/support/test_ecto_schemas/with_callbacks.ex new file mode 100644 index 0000000..27119d7 --- /dev/null +++ b/test/support/test_ecto_schemas/with_callbacks.ex @@ -0,0 +1,60 @@ +defmodule Fsmx.TestEctoSchemas.WithCallbacks do + defmodule ValidBefore do + use Ecto.Schema + + schema "test" do + field :state, :string + field :before, :string + end + + use Fsmx.Struct, transitions: %{"1" => "2"} + + def before_transition(struct, "1", _new_state) do + {:ok, %{struct | before: "1"}} + end + end + + defmodule InvalidBefore do + use Ecto.Schema + + schema "test" do + field :state, :string + field :before, :string + end + + use Fsmx.Struct, transitions: %{"1" => "2"} + + def before_transition(_struct, "1", _new_state) do + {:error, :before_failed} + end + end + + defmodule ValidAfterMulti do + use Ecto.Schema + + schema "test" do + field :state, :string + end + + use Fsmx.Struct, transitions: %{"1" => "2"} + + def after_transition_multi(_struct, "1", _new_state) do + send(self(), :after_transition_multi_called) + {:ok, nil} + end + end + + defmodule InvalidAfterMulti do + use Ecto.Schema + + schema "test" do + field :state, :string + end + + use Fsmx.Struct, transitions: %{"1" => "2"} + + def after_transition_multi(_struct, "1", _new_state) do + {:error, :after_transition_multi_failed} + end + end +end diff --git a/test/support/test_ecto_schemas/with_separate_fsm.ex b/test/support/test_ecto_schemas/with_separate_fsm.ex new file mode 100644 index 0000000..87ea23e --- /dev/null +++ b/test/support/test_ecto_schemas/with_separate_fsm.ex @@ -0,0 +1,16 @@ +defmodule Fsmx.TestEctoSchemas.WithSeparateFsm do + use Ecto.Schema + + schema "test" do + field :state, :string + field :before, :string + end + + use Fsmx.Struct, fsm: __MODULE__.Fsm + + defmodule Fsm do + use Fsmx.Fsm, transitions: %{"1" => "2"} + + def before_transition(schema, "1", _), do: {:ok, %{schema | before: "1"}} + end +end diff --git a/test/support/test_structs/with_callbacks.ex b/test/support/test_structs/with_callbacks.ex index 8939412..84af8bc 100644 --- a/test/support/test_structs/with_callbacks.ex +++ b/test/support/test_structs/with_callbacks.ex @@ -1,50 +1,30 @@ defmodule Fsmx.TestStructs.WithCallbacks do defmodule ValidBefore do - defstruct state: "1", before: false, after: false + defstruct state: "1", before: false use Fsmx.Struct, transitions: %{"1" => "2"} - def before_transition(struct, _old_state, _new_state) do - {:ok, %{struct | before: true}} - end - end - - defmodule ValidAfter do - defstruct state: "1", before: false, after: false - - use Fsmx.Struct, transitions: %{"1" => "2"} - - def after_transition(struct, _old_state, _new_state) do - {:ok, %{struct | after: true}} + def before_transition(struct, "1", _new_state) do + {:ok, %{struct | before: "1"}} end end defmodule InvalidBefore do - defstruct state: "1", before: false, after: false + defstruct state: "1", before: false use Fsmx.Struct, transitions: %{"1" => "2"} - def before_transition(struct, _old_state, _new_state) do + def before_transition(_struct, "1", _new_state) do {:error, :before_failed} end end - defmodule InvalidAfter do - defstruct state: "1", before: false, after: false - - use Fsmx.Struct, transitions: %{"1" => "2"} - - def after_transition(struct, _old_state, _new_state) do - {:error, :after_failed} - end - end - defmodule PartialCallback do - defstruct state: "1", before: false, after: false + defstruct state: "1", before: false use Fsmx.Struct, transitions: %{"1" => "2", "2" => "3"} - def after_transition(struct, "1", "2") do + def before_transition(struct, "1", "2") do {:ok, struct} end end diff --git a/test/support/test_structs/with_separate_fsm.ex b/test/support/test_structs/with_separate_fsm.ex index 21e7ef9..f25a1ec 100644 --- a/test/support/test_structs/with_separate_fsm.ex +++ b/test/support/test_structs/with_separate_fsm.ex @@ -1,13 +1,11 @@ defmodule Fsmx.TestStructs.WithSeparateFsm do - defstruct [:state, before: false, after: false] + defstruct [:state, before: false] use Fsmx.Struct, fsm: __MODULE__.Fsm defmodule Fsm do use Fsmx.Fsm, transitions: %{"1" => "2"} - def before_transition(struct, _, _), do: {:ok, %{struct | before: true}} - - def after_transition(struct, _, _), do: {:ok, %{struct | after: true}} + def before_transition(struct, "1", _), do: {:ok, %{struct | before: "1"}} end end diff --git a/test/test_helper.exs b/test/test_helper.exs index 869559e..d97a1e2 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1 +1,5 @@ +{:ok, _} = Fsmx.Repo.start_link() + ExUnit.start() + +Ecto.Adapters.SQL.Sandbox.mode(Fsmx.Repo, :manual)