├── config ├── dev.exs ├── config.exs └── test.exs ├── test ├── support │ ├── ecto │ │ ├── repo.ex │ │ ├── user.ex │ │ ├── migration.ex │ │ └── sale.ex │ ├── ecto_case.ex │ └── sale_workflow.ex ├── test_helper.exs ├── ex_state_test.exs └── ex_state │ ├── examples │ └── vending_machine_test.exs │ ├── definition │ └── compiler_test.exs │ └── definition_test.exs ├── lib ├── ex_state │ ├── result.ex │ ├── definition │ │ ├── transition.ex │ │ ├── step.ex │ │ ├── chart.ex │ │ ├── state.ex │ │ └── compiler.ex │ ├── ecto │ │ ├── workflow.ex │ │ ├── model.ex │ │ ├── migration.ex │ │ ├── workflow_step.ex │ │ ├── subject.ex │ │ ├── multi.ex │ │ └── query.ex │ ├── definition.ex │ └── execution.ex └── ex_state.ex ├── .gitignore ├── .formatter.exs ├── LICENSE ├── mix.exs ├── mix.lock └── README.md /config/dev.exs: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | import_config "#{Mix.env()}.exs" 4 | -------------------------------------------------------------------------------- /test/support/ecto/repo.ex: -------------------------------------------------------------------------------- 1 | defmodule ExState.TestSupport.Repo do 2 | use Ecto.Repo, 3 | otp_app: :ex_state, 4 | adapter: Ecto.Adapters.Postgres 5 | end 6 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | {:ok, _} = ExState.TestSupport.Repo.start_link() 2 | _ = Ecto.Migrator.up(ExState.TestSupport.Repo, 1, ExState.TestSupport.Migration) 3 | ExUnit.start() 4 | -------------------------------------------------------------------------------- /test/support/ecto/user.ex: -------------------------------------------------------------------------------- 1 | defmodule ExState.TestSupport.User do 2 | use Ecto.Schema 3 | 4 | import Ecto.Changeset 5 | 6 | schema "users" do 7 | field :name, :string 8 | end 9 | 10 | def new(params) do 11 | changeset(%__MODULE__{}, params) 12 | end 13 | 14 | def changeset(sale, params) do 15 | sale 16 | |> cast(params, [:name]) 17 | |> validate_required([:name]) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | config :logger, backends: [:console] 4 | config :logger, :console, level: :warn 5 | 6 | # Test Support Repos 7 | config :ex_state, ExState.TestSupport.Repo, 8 | username: "postgres", 9 | password: "postgres", 10 | database: "ex_state_test", 11 | hostname: "localhost", 12 | pool: Ecto.Adapters.SQL.Sandbox 13 | 14 | config :ex_state, ecto_repos: [ExState.TestSupport.Repo] 15 | 16 | # ExState's Repo 17 | config :ex_state, repo: ExState.TestSupport.Repo 18 | -------------------------------------------------------------------------------- /lib/ex_state/result.ex: -------------------------------------------------------------------------------- 1 | defmodule ExState.Result do 2 | def get({:ok, result}), do: result 3 | 4 | def map({:ok, result}, f), do: {:ok, f.(result)} 5 | def map(e, _), do: e 6 | 7 | def flat_map({:ok, result}, f), do: f.(result) 8 | def flat_map(e, _), do: e 9 | 10 | defmodule Multi do 11 | def extract({:ok, result}, key) do 12 | {:ok, Map.get(result, key)} 13 | end 14 | 15 | def extract({:error, _, reason, _}, _key) do 16 | {:error, reason} 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /test/support/ecto/migration.ex: -------------------------------------------------------------------------------- 1 | defmodule ExState.TestSupport.Migration do 2 | use Ecto.Migration 3 | 4 | def up do 5 | create table(:users) do 6 | add(:name, :string, null: false) 7 | end 8 | 9 | ExState.Ecto.Migration.up(install_pgcrypto: true) 10 | 11 | create table(:sales) do 12 | add(:product_id, :string, null: false) 13 | add(:cancelled_at, :utc_datetime) 14 | add(:seller_id, references(:users)) 15 | add(:buyer_id, references(:users)) 16 | add(:workflow_id, references(:workflows, type: :uuid)) 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | workflow-*.tar 24 | 25 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | import_deps: [:ecto], 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"], 4 | locals_without_parens: [ 5 | has_workflow: 1, 6 | has_workflow: 2, 7 | has_workflow: 3, 8 | workflow: 1, 9 | participant: 1, 10 | initial_state: 1, 11 | subject: 1, 12 | subject: 2, 13 | state: 1, 14 | state: 2, 15 | virtual: 2, 16 | using: 1, 17 | step: 1, 18 | step: 2, 19 | repeatable: 1, 20 | on: 2, 21 | on: 3, 22 | on_entry: 1, 23 | on_exit: 1, 24 | on_completed: 2, 25 | on_completed: 3, 26 | on_decision: 3, 27 | on_decision: 4, 28 | on_no_steps: 1, 29 | on_no_steps: 2, 30 | on_final: 1, 31 | on_final: 2 32 | ] 33 | ] 34 | -------------------------------------------------------------------------------- /lib/ex_state/definition/transition.ex: -------------------------------------------------------------------------------- 1 | defmodule ExState.Definition.Transition do 2 | @type event :: atom() | {:completed, atom()} | {:decision, atom(), atom()} 3 | 4 | @type t :: %__MODULE__{ 5 | event: event(), 6 | reset: boolean(), 7 | target: String.t() | [String.t()], 8 | actions: [atom()] 9 | } 10 | 11 | defstruct event: nil, reset: true, target: nil, actions: [] 12 | 13 | def new(event, target, opts \\ []) do 14 | reset = Keyword.get(opts, :reset, true) 15 | action = Keyword.get(opts, :action, nil) 16 | actions = if action, do: [action], else: Keyword.get(opts, :actions, []) 17 | 18 | %__MODULE__{ 19 | event: event, 20 | target: target, 21 | actions: actions, 22 | reset: reset 23 | } 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /test/support/ecto/sale.ex: -------------------------------------------------------------------------------- 1 | defmodule ExState.TestSupport.Sale do 2 | use Ecto.Schema 3 | use ExState.Ecto.Subject 4 | 5 | import Ecto.Changeset 6 | 7 | alias ExState.TestSupport.SaleWorkflow 8 | alias ExState.TestSupport.User 9 | 10 | schema "sales" do 11 | has_workflow SaleWorkflow 12 | field :product_id, :string 13 | field :cancelled_at, :utc_datetime 14 | belongs_to :seller, User 15 | belongs_to :buyer, User 16 | end 17 | 18 | def new(params) do 19 | changeset(%__MODULE__{}, params) 20 | end 21 | 22 | def changeset(sale, params) do 23 | sale 24 | |> cast(params, [ 25 | :product_id, 26 | :cancelled_at, 27 | :seller_id, 28 | :buyer_id, 29 | :workflow_id 30 | ]) 31 | |> validate_required([:product_id]) 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/ex_state/ecto/workflow.ex: -------------------------------------------------------------------------------- 1 | defmodule ExState.Ecto.Workflow do 2 | use ExState.Ecto.Model 3 | 4 | alias ExState.Ecto.WorkflowStep 5 | 6 | schema "workflows" do 7 | field :name, :string 8 | field :state, :string 9 | field :complete?, :boolean, default: false, source: :is_complete 10 | field :lock_version, :integer, default: 1 11 | 12 | has_many :steps, WorkflowStep, on_replace: :delete 13 | 14 | timestamps() 15 | end 16 | 17 | @required_attrs [ 18 | :name, 19 | :state 20 | ] 21 | 22 | @optional_attrs [ 23 | :complete? 24 | ] 25 | 26 | def changeset(workflow, attrs) do 27 | workflow 28 | |> cast(attrs, @required_attrs ++ @optional_attrs) 29 | |> validate_required(@required_attrs) 30 | |> optimistic_lock(:lock_version) 31 | end 32 | 33 | def completed_steps(workflow) do 34 | Enum.filter(workflow.steps, fn step -> step.complete? end) 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/ex_state/ecto/model.ex: -------------------------------------------------------------------------------- 1 | defmodule ExState.Ecto.Model do 2 | defmacro __using__(opts \\ []) do 3 | primary_key = Keyword.get(opts, :primary_key, []) 4 | primary_key_type = Keyword.get(primary_key, :type, Ecto.UUID) 5 | autogenerate = Keyword.get(primary_key, :autogenerate, false) 6 | read_after_writes = !autogenerate 7 | 8 | quote do 9 | use Ecto.Schema 10 | 11 | @primary_key {:id, unquote(primary_key_type), 12 | autogenerate: unquote(autogenerate), 13 | read_after_writes: unquote(read_after_writes)} 14 | @foreign_key_type Ecto.UUID 15 | @timestamps_opts [type: :utc_datetime_usec] 16 | 17 | import Ecto.Changeset 18 | import unquote(__MODULE__) 19 | 20 | @type t :: %__MODULE__{} 21 | 22 | def new(attrs) do 23 | new(__MODULE__, attrs) 24 | end 25 | 26 | defoverridable new: 1 27 | end 28 | end 29 | 30 | def new(mod, attrs) do 31 | mod.changeset(struct(mod), attrs) 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/ex_state/definition/step.ex: -------------------------------------------------------------------------------- 1 | defmodule ExState.Definition.Step do 2 | @type t :: %__MODULE__{ 3 | name: String.t(), 4 | participant: atom(), 5 | order: integer(), 6 | complete?: boolean(), 7 | decision: atom() 8 | } 9 | 10 | defstruct name: nil, participant: nil, order: 1, complete?: false, decision: nil 11 | 12 | def new(id, participant) do 13 | %__MODULE__{name: name(id), participant: participant} 14 | end 15 | 16 | def order(s, o) do 17 | %__MODULE__{s | order: o} 18 | end 19 | 20 | def complete(s, decision \\ nil) do 21 | %__MODULE__{s | complete?: true, decision: decision} 22 | end 23 | 24 | def name(id) when is_atom(id), do: Atom.to_string(id) 25 | def name(id) when is_bitstring(id), do: id 26 | 27 | # The atom may not exist due to being converted to string at compile time. 28 | # Should be safe to use to_atom here since this API shouldn't be 29 | # exposed to external input. 30 | def id(step), do: String.to_atom(step.name) 31 | end 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 8th Light 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /test/support/ecto_case.ex: -------------------------------------------------------------------------------- 1 | defmodule ExState.TestSupport.EctoCase do 2 | use ExUnit.CaseTemplate 3 | 4 | using do 5 | quote do 6 | alias ExState.TestSupport.Repo 7 | 8 | import Ecto 9 | import Ecto.Changeset 10 | import Ecto.Query 11 | import ExState.TestSupport.EctoCase 12 | end 13 | end 14 | 15 | setup tags do 16 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(ExState.TestSupport.Repo) 17 | 18 | unless tags[:async] do 19 | Ecto.Adapters.SQL.Sandbox.mode(ExState.TestSupport.Repo, {:shared, self()}) 20 | end 21 | 22 | :ok 23 | end 24 | 25 | @doc """ 26 | A helper that transforms changeset errors into a map of messages. 27 | 28 | assert {:error, changeset} = Accounts.create_user(%{password: "short"}) 29 | assert "password is too short" in errors_on(changeset).password 30 | assert %{password: ["password is too short"]} = errors_on(changeset) 31 | 32 | """ 33 | def errors_on(changeset) do 34 | Ecto.Changeset.traverse_errors(changeset, fn {message, opts} -> 35 | Enum.reduce(opts, message, fn {key, value}, acc -> 36 | String.replace(acc, "%{#{key}}", format_value(value)) 37 | end) 38 | end) 39 | end 40 | 41 | defp format_value({k, v}) do 42 | "#{to_string(k)} #{to_string(v)}" 43 | end 44 | 45 | defp format_value(value) do 46 | to_string(value) 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/ex_state/ecto/migration.ex: -------------------------------------------------------------------------------- 1 | defmodule ExState.Ecto.Migration do 2 | use Ecto.Migration 3 | 4 | def up(opts \\ []) do 5 | if Keyword.get(opts, :install_pgcrypto, false) do 6 | execute("CREATE EXTENSION IF NOT EXISTS pgcrypto") 7 | end 8 | 9 | create table(:workflows, primary_key: false) do 10 | add_uuid_primary_key() 11 | add(:name, :string, null: false) 12 | add(:state, :string, null: false) 13 | add(:is_complete, :boolean, null: false, default: false) 14 | add(:lock_version, :integer, default: 1) 15 | timestamps() 16 | end 17 | 18 | create table(:workflow_steps, primary_key: false) do 19 | add_uuid_primary_key() 20 | add(:state, :string, null: false) 21 | add(:name, :string, null: false) 22 | add(:order, :integer, null: false) 23 | add(:decision, :string) 24 | add(:participant, :string) 25 | add(:is_complete, :boolean, null: false, default: false) 26 | add(:workflow_id, references(:workflows, type: :uuid, on_delete: :delete_all), null: false) 27 | add(:completed_at, :utc_datetime_usec) 28 | add(:completed_metadata, :map) 29 | timestamps() 30 | end 31 | 32 | create(unique_index(:workflow_steps, [:workflow_id, :state, :name])) 33 | create(index(:workflow_steps, [:participant])) 34 | end 35 | 36 | defp add_uuid_primary_key do 37 | add(:id, :uuid, primary_key: true, default: {:fragment, "gen_random_uuid()"}) 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/ex_state/ecto/workflow_step.ex: -------------------------------------------------------------------------------- 1 | defmodule ExState.Ecto.WorkflowStep do 2 | use ExState.Ecto.Model 3 | 4 | alias ExState.Ecto.Workflow 5 | 6 | schema "workflow_steps" do 7 | field :state, :string 8 | field :name, :string 9 | field :order, :integer 10 | field :decision, :string 11 | field :participant, :string 12 | field :complete?, :boolean, default: false, source: :is_complete 13 | field :completed_at, :utc_datetime_usec 14 | field :completed_metadata, :map 15 | 16 | belongs_to :workflow, Workflow 17 | 18 | timestamps() 19 | end 20 | 21 | @required_attrs [ 22 | :state, 23 | :name, 24 | :order 25 | ] 26 | 27 | @optional_attrs [ 28 | :workflow_id, 29 | :participant, 30 | :complete?, 31 | :completed_at, 32 | :completed_metadata 33 | ] 34 | 35 | def changeset(workflow_step, attrs) do 36 | workflow_step 37 | |> cast(attrs, @required_attrs ++ @optional_attrs) 38 | |> validate_required(@required_attrs) 39 | end 40 | 41 | def put_completion(changeset, metadata) do 42 | case fetch_change(changeset, :complete?) do 43 | {:ok, true} -> 44 | changeset 45 | |> put_change(:completed_metadata, metadata) 46 | |> put_change(:completed_at, DateTime.utc_now()) 47 | 48 | {:ok, false} -> 49 | changeset 50 | |> put_change(:completed_metadata, nil) 51 | |> put_change(:completed_at, nil) 52 | 53 | :error -> 54 | changeset 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule ExState.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :ex_state, 7 | version: "0.3.0", 8 | elixir: "~> 1.9", 9 | elixirc_paths: elixirc_paths(Mix.env()), 10 | start_permanent: Mix.env() == :prod, 11 | deps: deps(), 12 | description: description(), 13 | package: package(), 14 | source_url: "https://github.com/8thlight/ex_state", 15 | docs: [ 16 | main: "ExState", 17 | extras: ["README.md"] 18 | ], 19 | dialyzer: [ 20 | plt_add_deps: :app_tree, 21 | plt_add_apps: [:mix], 22 | flags: [:error_handling, :race_conditions, :underspecs] 23 | ] 24 | ] 25 | end 26 | 27 | def application do 28 | [ 29 | extra_applications: [:logger] 30 | ] 31 | end 32 | 33 | defp elixirc_paths(:test), do: ["lib", "test/support"] 34 | defp elixirc_paths(_), do: ["lib"] 35 | 36 | defp deps do 37 | [ 38 | {:ecto_sql, "~> 3.0"}, 39 | {:postgrex, ">= 0.0.0"}, 40 | {:jason, "~> 1.0"}, 41 | {:gettext, "~> 0.11"}, 42 | {:dialyxir, "~> 1.0.0-rc.7", only: [:dev, :test], runtime: false}, 43 | {:ex_doc, ">= 0.0.0", only: :dev, runtime: false} 44 | ] 45 | end 46 | 47 | defp description() do 48 | "State machines, statecharts and workflows for Ecto models." 49 | end 50 | 51 | defp package() do 52 | [ 53 | name: "ex_state_ecto", 54 | files: ~w(lib .formatter.exs mix.exs README* LICENSE*), 55 | licenses: ["MIT"], 56 | links: %{"GitHub" => "https://github.com/8thlight/ex_state"} 57 | ] 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /test/support/sale_workflow.ex: -------------------------------------------------------------------------------- 1 | defmodule ExState.TestSupport.SaleWorkflow do 2 | use ExState.Definition 3 | 4 | alias ExState.TestSupport.Repo 5 | alias ExState.TestSupport.Sale 6 | 7 | workflow "sale" do 8 | subject :sale, Sale 9 | 10 | participant :seller 11 | participant :buyer 12 | 13 | initial_state :pending 14 | 15 | state :unknown do 16 | on :_, [:morning] 17 | end 18 | 19 | state :pending do 20 | step :attach_document, participant: :seller 21 | step :send, participant: :seller 22 | on :cancelled, :cancelled 23 | on :document_replaced, :_ 24 | on_completed :send, :sent 25 | end 26 | 27 | state :sent do 28 | parallel do 29 | step :acknowledge_receipt, participant: :buyer 30 | step :close, participant: :seller 31 | end 32 | 33 | on :cancelled, :cancelled 34 | on :document_replaced, :pending 35 | on_completed :acknowledge_receipt, :receipt_acknowledged 36 | on_completed :close, :closed 37 | end 38 | 39 | state :receipt_acknowledged do 40 | step :close, participant: :seller 41 | on_completed :close, :closed 42 | end 43 | 44 | state :closed do 45 | final 46 | end 47 | 48 | state :cancelled do 49 | final 50 | on_entry :update_cancelled_at 51 | end 52 | end 53 | 54 | def use_step?(_sale, _step), do: true 55 | 56 | def guard_transition(_sale, _from, _to), do: :ok 57 | 58 | def update_cancelled_at(%{sale: sale}) do 59 | sale 60 | |> Sale.changeset(%{cancelled_at: DateTime.utc_now()}) 61 | |> Repo.update() 62 | |> updated(:sale) 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /lib/ex_state/ecto/subject.ex: -------------------------------------------------------------------------------- 1 | defmodule ExState.Ecto.Subject do 2 | defmacro __using__(_) do 3 | quote do 4 | import unquote(__MODULE__), only: [has_workflow: 1, has_workflow: 3] 5 | 6 | @before_compile unquote(__MODULE__) 7 | end 8 | end 9 | 10 | defmacro __before_compile__(_env) do 11 | quote do 12 | def workflow_association, do: elem(@ex_state_workflow, 0) 13 | def workflow_definition, do: elem(@ex_state_workflow, 1) 14 | end 15 | end 16 | 17 | defmacro has_workflow(field_name, definition, opts \\ []) do 18 | definition = expand_alias(definition, __CALLER__) 19 | 20 | quote bind_quoted: [field_name: field_name, definition: definition, opts: opts] do 21 | Module.put_attribute(__MODULE__, :ex_state_workflow, {field_name, definition}) 22 | belongs_to field_name, ExState.Ecto.Workflow, Keyword.put(opts, :type, Ecto.UUID) 23 | end 24 | end 25 | 26 | defmacro has_workflow(definition) do 27 | definition = expand_alias(definition, __CALLER__) 28 | 29 | quote bind_quoted: [definition: definition] do 30 | Module.put_attribute(__MODULE__, :ex_state_workflow, {:workflow, definition}) 31 | belongs_to :workflow, ExState.Ecto.Workflow, type: Ecto.UUID 32 | end 33 | end 34 | 35 | defp expand_alias({:__aliases__, _, _} = ast, env), do: Macro.expand(ast, env) 36 | defp expand_alias(ast, _env), do: ast 37 | 38 | def workflow_definition(%module{} = _subject) do 39 | module.workflow_definition() 40 | end 41 | 42 | def workflow_association(%module{} = _subject) do 43 | module.workflow_association() 44 | end 45 | 46 | def put_workflow(subject, workflow) do 47 | Map.put(subject, workflow_association(subject), workflow) 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/ex_state/definition/chart.ex: -------------------------------------------------------------------------------- 1 | defmodule ExState.Definition.Chart do 2 | alias ExState.Definition.State 3 | 4 | @type subject :: {atom(), module()} 5 | 6 | @type t :: %__MODULE__{ 7 | name: String.t(), 8 | subject: subject() | nil, 9 | initial_state: atom(), 10 | states: %{required(String.t()) => State.t()}, 11 | participants: [atom()] 12 | } 13 | 14 | defstruct name: nil, subject: nil, initial_state: nil, states: %{}, participants: [] 15 | 16 | def transitions(chart) do 17 | chart.states 18 | |> Map.values() 19 | |> Enum.flat_map(&State.transitions/1) 20 | end 21 | 22 | def events(chart) do 23 | chart 24 | |> transitions() 25 | |> Enum.map(fn transition -> Atom.to_string(transition.event) end) 26 | |> Enum.uniq() 27 | end 28 | 29 | def states(chart) do 30 | Map.values(chart.states) 31 | end 32 | 33 | def state_names(chart) do 34 | chart 35 | |> states() 36 | |> Enum.map(fn state -> state.name end) 37 | end 38 | 39 | def steps(chart) do 40 | chart.states 41 | |> Enum.flat_map(fn {_, state} -> state.steps end) 42 | end 43 | 44 | def step_names(chart) do 45 | chart 46 | |> steps() 47 | |> Enum.map(fn step -> step.name end) 48 | end 49 | 50 | def state(chart, name) when is_bitstring(name) do 51 | Map.get(chart.states, name) 52 | end 53 | 54 | def state(chart, id) when is_atom(id) do 55 | state(chart, State.combine([id])) 56 | end 57 | 58 | def state(chart, id1, id2) when is_atom(id1) and is_atom(id2) do 59 | state(chart, State.combine([id1, id2])) 60 | end 61 | 62 | def parent(chart, %State{name: name}) do 63 | state(chart, State.parent(name)) 64 | end 65 | 66 | def put_states(chart, states) do 67 | %__MODULE__{chart | states: states} 68 | end 69 | 70 | def put_participant(chart, participant) do 71 | %__MODULE__{chart | participants: [participant | chart.participants]} 72 | end 73 | 74 | def participant_names(chart) do 75 | chart.participants 76 | |> Enum.map(&Atom.to_string/1) 77 | end 78 | 79 | def describe(chart) do 80 | %{ 81 | "states" => state_names(chart), 82 | "steps" => step_names(chart), 83 | "events" => events(chart), 84 | "participants" => participant_names(chart) 85 | } 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /lib/ex_state/ecto/multi.ex: -------------------------------------------------------------------------------- 1 | defmodule ExState.Ecto.Multi do 2 | @moduledoc """ 3 | `ExState.Ecto.Multi` provides convencience functions for creating and 4 | transitioning workflows in Ecto.Multi operations. 5 | 6 | ## Example 7 | 8 | def create_sale() do 9 | Multi.new() 10 | |> Multi.create(:sale, Sale.new()) 11 | |> ExState.Ecto.Multi.create(:sale) 12 | |> ExState.Ecto.Multi.transition(:sale, :packed) 13 | |> Repo.transaction() 14 | end 15 | """ 16 | 17 | alias ExState 18 | alias Ecto.Multi 19 | 20 | @spec create(Multi.t(), struct() | atom()) :: Multi.t() 21 | def create(%Multi{} = multi, subject_key) when is_atom(subject_key) do 22 | Multi.merge(multi, fn results -> 23 | ExState.create_multi(Map.get(results, subject_key)) 24 | end) 25 | end 26 | 27 | def create(%Multi{} = multi, subject) do 28 | Multi.merge(multi, fn _ -> 29 | ExState.create_multi(subject) 30 | end) 31 | end 32 | 33 | @spec transition(Multi.t(), struct() | atom(), any(), keyword()) :: Multi.t() 34 | def transition(multi, subject_or_key, event, opts \\ []) 35 | 36 | def transition(%Multi{} = multi, subject_key, event, opts) when is_atom(subject_key) do 37 | Multi.run(multi, event, fn _repo, results -> 38 | ExState.transition(Map.get(results, subject_key), event, opts) 39 | end) 40 | end 41 | 42 | def transition(%Multi{} = multi, subject, event, opts) do 43 | Multi.run(multi, event, fn _repo, _ -> 44 | ExState.transition(subject, event, opts) 45 | end) 46 | end 47 | 48 | @spec complete(Multi.t(), struct() | atom(), any(), keyword()) :: Multi.t() 49 | def complete(multi, subject_or_key, step_id, opts \\ []) 50 | 51 | def complete(%Multi{} = multi, subject_key, step_id, opts) when is_atom(subject_key) do 52 | Multi.run(multi, step_id, fn _repo, results -> 53 | ExState.complete(Map.get(results, subject_key), step_id, opts) 54 | end) 55 | end 56 | 57 | def complete(%Multi{} = multi, subject, step_id, opts) do 58 | Multi.run(multi, step_id, fn _repo, _ -> 59 | ExState.complete(subject, step_id, opts) 60 | end) 61 | end 62 | 63 | @spec decision(Multi.t(), struct() | atom(), any(), any(), keyword()) :: Multi.t() 64 | def decision(multi, subject_or_key, step_id, decision, opts \\ []) 65 | 66 | def decision(%Multi{} = multi, subject_key, step_id, decision, opts) 67 | when is_atom(subject_key) do 68 | Multi.run(multi, step_id, fn _repo, results -> 69 | ExState.decision(Map.get(results, subject_key), step_id, decision, opts) 70 | end) 71 | end 72 | 73 | def decision(%Multi{} = multi, subject, step_id, decision, opts) do 74 | Multi.run(multi, step_id, fn _repo, _ -> 75 | ExState.decision(subject, step_id, decision, opts) 76 | end) 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /test/ex_state_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExStateTest do 2 | use ExState.TestSupport.EctoCase, async: true 3 | 4 | alias ExState.TestSupport.Sale 5 | alias ExState.TestSupport.User 6 | 7 | doctest ExState 8 | 9 | def create_sale do 10 | seller = User.new(%{name: "seller"}) |> Repo.insert!() 11 | buyer = User.new(%{name: "seller"}) |> Repo.insert!() 12 | 13 | Sale.new(%{ 14 | product_id: "abc123", 15 | seller_id: seller.id, 16 | buyer_id: buyer.id 17 | }) 18 | |> Repo.insert!() 19 | end 20 | 21 | def order_steps(steps) do 22 | Enum.sort_by(steps, &"#{&1.state}.#{&1.order}") 23 | end 24 | 25 | describe "create/1" do 26 | test "creates a workflow for a workflowable subject" do 27 | sale = create_sale() 28 | 29 | {:ok, %{context: %{sale: sale}}} = ExState.create(sale) 30 | 31 | refute sale.workflow.complete? 32 | assert sale.workflow.state == "pending" 33 | 34 | assert [ 35 | %{state: "pending", name: "attach_document", complete?: false}, 36 | %{state: "pending", name: "send", complete?: false}, 37 | %{state: "receipt_acknowledged", name: "close", complete?: false}, 38 | %{state: "sent", name: "close", complete?: false}, 39 | %{state: "sent", name: "acknowledge_receipt", complete?: false} 40 | ] = order_steps(sale.workflow.steps) 41 | end 42 | end 43 | 44 | describe "transition/3" do 45 | setup do 46 | sale = create_sale() 47 | 48 | {:ok, %{context: %{sale: sale}}} = ExState.create(sale) 49 | 50 | [sale: sale] 51 | end 52 | 53 | test "transitions state", %{sale: sale} do 54 | {:ok, _} = ExState.complete(sale, :attach_document) 55 | {:ok, _} = ExState.complete(sale, :send) 56 | {:ok, sale} = ExState.transition(sale, :cancelled) 57 | 58 | assert sale.workflow.complete? 59 | assert sale.workflow.state == "cancelled" 60 | end 61 | 62 | test "transitions state through execution module", %{sale: sale} do 63 | {:ok, sale} = 64 | sale 65 | |> ExState.load() 66 | |> ExState.Execution.complete!(:attach_document) 67 | |> ExState.Execution.complete!(:send) 68 | |> ExState.Execution.transition!(:cancelled) 69 | |> ExState.persist() 70 | 71 | assert sale.workflow.complete? 72 | assert sale.workflow.state == "cancelled" 73 | end 74 | 75 | test "returns error for unknown transition", %{sale: sale} do 76 | {:ok, _} = ExState.complete(sale, :attach_document) 77 | {:ok, _} = ExState.complete(sale, :send) 78 | {:ok, _} = ExState.complete(sale, :acknowledge_receipt) 79 | {:error, _} = ExState.transition(sale, :cancelled) 80 | workflow = sale |> Ecto.assoc(:workflow) |> Repo.one() 81 | 82 | refute workflow.complete? 83 | assert workflow.state == "receipt_acknowledged" 84 | end 85 | 86 | test "returns subject with updates provided in actions", %{sale: sale} do 87 | {:ok, sale} = ExState.transition(sale, :cancelled) 88 | 89 | assert sale.cancelled_at != nil 90 | end 91 | end 92 | 93 | describe "complete/3" do 94 | setup do 95 | sale = create_sale() 96 | 97 | {:ok, %{context: %{sale: sale}}} = ExState.create(sale) 98 | 99 | [sale: sale] 100 | end 101 | 102 | test "completes a step", %{sale: sale} do 103 | {:ok, sale} = ExState.complete(sale, :attach_document) 104 | 105 | refute sale.workflow.complete? 106 | assert sale.workflow.state == "pending" 107 | 108 | assert [ 109 | %{state: "pending", name: "attach_document", complete?: true}, 110 | %{state: "pending", name: "send", complete?: false}, 111 | %{state: "receipt_acknowledged", name: "close", complete?: false}, 112 | %{state: "sent", name: "close", complete?: false}, 113 | %{state: "sent", name: "acknowledge_receipt", complete?: false} 114 | ] = order_steps(sale.workflow.steps) 115 | end 116 | 117 | test "adds user metadata", %{sale: sale} do 118 | user = User.new(%{name: "seller"}) |> Repo.insert!() 119 | {:ok, sale} = ExState.complete(sale, :attach_document, user_id: user.id) 120 | 121 | assert sale.workflow.steps 122 | |> Enum.find(fn s -> s.name == "attach_document" end) 123 | |> Map.get(:completed_metadata) 124 | |> Map.get(:user_id) == user.id 125 | end 126 | end 127 | end 128 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], [], "hexpm", "4a0850c9be22a43af9920a71ab17c051f5f7d45c209e40269a1938832510e4d9"}, 3 | "db_connection": {:hex, :db_connection, "2.2.1", "caee17725495f5129cb7faebde001dc4406796f12a62b8949f4ac69315080566", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}], "hexpm", "2b02ece62d9f983fcd40954e443b7d9e6589664380e5546b2b9b523cd0fb59e1"}, 4 | "decimal": {:hex, :decimal, "1.8.1", "a4ef3f5f3428bdbc0d35374029ffcf4ede8533536fa79896dd450168d9acdf3c", [:mix], [], "hexpm", "3cb154b00225ac687f6cbd4acc4b7960027c757a5152b369923ead9ddbca7aec"}, 5 | "dialyxir": {:hex, :dialyxir, "1.0.0-rc.7", "6287f8f2cb45df8584317a4be1075b8c9b8a69de8eeb82b4d9e6c761cf2664cd", [:mix], [{:erlex, ">= 0.2.5", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "506294d6c543e4e5282d4852aead19ace8a35bedeb043f9256a06a6336827122"}, 6 | "earmark": {:hex, :earmark, "1.4.3", "364ca2e9710f6bff494117dbbd53880d84bebb692dafc3a78eb50aa3183f2bfd", [:mix], [], "hexpm", "8cf8a291ebf1c7b9539e3cddb19e9cef066c2441b1640f13c34c1d3cfc825fec"}, 7 | "ecto": {:hex, :ecto, "3.3.4", "95b05c82ae91361475e5491c9f3ac47632f940b3f92ae3988ac1aad04989c5bb", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "9b96cbb83a94713731461ea48521b178b0e3863d310a39a3948c807266eebd69"}, 8 | "ecto_sql": {:hex, :ecto_sql, "3.3.4", "aa18af12eb875fbcda2f75e608b3bd534ebf020fc4f6448e4672fcdcbb081244", [:mix], [{:db_connection, "~> 2.2", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.4 or ~> 3.3.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.3.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5eccbdbf92e3c6f213007a82d5dbba4cd9bb659d1a21331f89f408e4c0efd7a8"}, 9 | "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, 10 | "ex_doc": {:hex, :ex_doc, "0.21.3", "857ec876b35a587c5d9148a2512e952e24c24345552259464b98bfbb883c7b42", [:mix], [{:earmark, "~> 1.4", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm", "0db1ee8d1547ab4877c5b5dffc6604ef9454e189928d5ba8967d4a58a801f161"}, 11 | "gettext": {:hex, :gettext, "0.17.4", "f13088e1ec10ce01665cf25f5ff779e7df3f2dc71b37084976cf89d1aa124d5c", [:mix], [], "hexpm", "3c75b5ea8288e2ee7ea503ff9e30dfe4d07ad3c054576a6e60040e79a801e14d"}, 12 | "jason": {:hex, :jason, "1.1.2", "b03dedea67a99223a2eaf9f1264ce37154564de899fd3d8b9a21b1a6fd64afe7", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fdf843bca858203ae1de16da2ee206f53416bbda5dc8c9e78f43243de4bc3afe"}, 13 | "makeup": {:hex, :makeup, "1.0.1", "82f332e461dc6c79dbd82fbe2a9c10d48ed07146f0a478286e590c83c52010b5", [:mix], [{:nimble_parsec, "~> 0.5.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "49736fe5b66a08d8575bf5321d716bac5da20c8e6b97714fec2bcd6febcfa1f8"}, 14 | "makeup_elixir": {:hex, :makeup_elixir, "0.14.0", "cf8b7c66ad1cff4c14679698d532f0b5d45a3968ffbcbfd590339cb57742f1ae", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "d4b316c7222a85bbaa2fd7c6e90e37e953257ad196dc229505137c5e505e9eff"}, 15 | "nimble_parsec": {:hex, :nimble_parsec, "0.5.3", "def21c10a9ed70ce22754fdeea0810dafd53c2db3219a0cd54cf5526377af1c6", [:mix], [], "hexpm", "589b5af56f4afca65217a1f3eb3fee7e79b09c40c742fddc1c312b3ac0b3399f"}, 16 | "poison": {:hex, :poison, "4.0.1", "bcb755a16fac91cad79bfe9fc3585bb07b9331e50cfe3420a24bcc2d735709ae", [:mix], [], "hexpm", "ba8836feea4b394bb718a161fc59a288fe0109b5006d6bdf97b6badfcf6f0f25"}, 17 | "postgrex": {:hex, :postgrex, "0.15.3", "5806baa8a19a68c4d07c7a624ccdb9b57e89cbc573f1b98099e3741214746ae4", [: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", "4737ce62a31747b4c63c12b20c62307e51bb4fcd730ca0c32c280991e0606c90"}, 18 | "telemetry": {:hex, :telemetry, "0.4.1", "ae2718484892448a24470e6aa341bc847c3277bfb8d4e9289f7474d752c09c7f", [:rebar3], [], "hexpm", "4738382e36a0a9a2b6e25d67c960e40e1a2c95560b9f936d8e29de8cd858480f"}, 19 | } 20 | -------------------------------------------------------------------------------- /test/ex_state/examples/vending_machine_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExState.Examples.VendingMachineTest do 2 | use ExUnit.Case, async: true 3 | 4 | defmodule VendingMachine do 5 | use ExState.Definition 6 | 7 | defmodule Context do 8 | defstruct coins: [], refunded: [], item: nil, vending: nil, vended: nil 9 | 10 | def new do 11 | %__MODULE__{} 12 | end 13 | 14 | def put_coin(context, coin) do 15 | %{context | coins: [coin | context.coins]} 16 | end 17 | 18 | def paid?(context) do 19 | Enum.sum(context.coins) >= 100 20 | end 21 | 22 | def select(context, item) do 23 | %{context | item: item} 24 | end 25 | 26 | def vend(context) do 27 | %{context | vending: context.item, item: nil, coins: []} 28 | end 29 | 30 | def vended(context) do 31 | %{context | vended: context.vending, vending: nil} 32 | end 33 | 34 | def refund(context) do 35 | %{context | refunded: context.coins, coins: []} 36 | end 37 | end 38 | 39 | workflow "vending" do 40 | initial_state :working 41 | 42 | state :working do 43 | initial_state :waiting 44 | 45 | on :broken, :out_of_order 46 | 47 | state :waiting do 48 | on :coin, :calculating 49 | end 50 | 51 | state :calculating do 52 | on :_, [:paid, :paying] 53 | end 54 | 55 | state :paying do 56 | on :return, :waiting, action: :refund 57 | on :coin, :calculating 58 | end 59 | 60 | state :paid do 61 | on :return, :waiting, action: :refund 62 | on :select, :vending 63 | end 64 | 65 | state :vending do 66 | on_entry :vend 67 | on :vended, :waiting 68 | on_exit :vend_complete 69 | end 70 | end 71 | 72 | state :out_of_order do 73 | on :fixed, :working 74 | end 75 | end 76 | 77 | def guard_transition(:calculating, :paid, context) do 78 | if Context.paid?(context) do 79 | :ok 80 | else 81 | {:error, "not paid"} 82 | end 83 | end 84 | 85 | def guard_transition(_, _, _), do: :ok 86 | 87 | def refund(context) do 88 | {:updated, Context.refund(context)} 89 | end 90 | 91 | def vend(context) do 92 | {:updated, Context.vend(context)} 93 | end 94 | 95 | def vend_complete(context) do 96 | {:updated, Context.vended(context)} 97 | end 98 | 99 | def add_coin(%{context: context} = execution, coin) do 100 | execution 101 | |> put_context(Context.put_coin(context, coin)) 102 | |> transition!(:coin) 103 | |> execute_actions!() 104 | end 105 | 106 | def select(%{context: context} = execution, item) do 107 | execution 108 | |> put_context(Context.select(context, item)) 109 | |> transition!(:select) 110 | |> execute_actions!() 111 | end 112 | 113 | def return(execution) do 114 | execution 115 | |> transition!(:return) 116 | |> execute_actions!() 117 | end 118 | 119 | def vended(execution) do 120 | execution 121 | |> transition!(:vended) 122 | |> execute_actions!() 123 | end 124 | end 125 | 126 | test "calculates payment" do 127 | %{state: state, context: context} = 128 | execution = 129 | VendingMachine.Context.new() 130 | |> VendingMachine.new() 131 | |> VendingMachine.add_coin(10) 132 | |> VendingMachine.add_coin(25) 133 | |> VendingMachine.add_coin(25) 134 | |> VendingMachine.add_coin(25) 135 | |> VendingMachine.add_coin(10) 136 | 137 | assert state.name == "working.paying" 138 | assert context.coins == [10, 25, 25, 25, 10] 139 | 140 | %{state: state, context: context} = 141 | execution = 142 | execution 143 | |> VendingMachine.add_coin(5) 144 | 145 | assert state.name == "working.paid" 146 | assert context.coins == [5, 10, 25, 25, 25, 10] 147 | 148 | %{state: state, context: context} = 149 | execution = 150 | execution 151 | |> VendingMachine.select(:a1) 152 | 153 | assert state.name == "working.vending" 154 | assert context.vending == :a1 155 | assert context.coins == [] 156 | 157 | %{state: state, context: context} = 158 | execution = 159 | execution 160 | |> VendingMachine.vended() 161 | 162 | assert state.name == "working.waiting" 163 | assert context.vended == :a1 164 | assert context.coins == [] 165 | 166 | %{state: state, context: context} = 167 | execution = 168 | execution 169 | |> VendingMachine.add_coin(10) 170 | |> VendingMachine.add_coin(25) 171 | 172 | assert state.name == "working.paying" 173 | assert context.coins == [25, 10] 174 | 175 | %{state: state, context: context} = 176 | execution = 177 | execution 178 | |> VendingMachine.return() 179 | 180 | assert state.name == "working.waiting" 181 | assert context.coins == [] 182 | 183 | %{state: state, context: _context} = 184 | execution 185 | |> VendingMachine.transition!(:broken) 186 | 187 | assert state.name == "out_of_order" 188 | end 189 | end 190 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ExState 2 | 3 | [![Hex.pm](https://img.shields.io/hexpm/v/ex_state_ecto.svg)](https://hex.pm/packages/ex_state_ecto) 4 | [![Hex Docs](https://img.shields.io/badge/hexdocs-release-blue.svg)](https://hexdocs.pm/ex_state_ecto/ExState.html) 5 | 6 | Elixir state machines, statecharts, and workflows for Ecto models. 7 | 8 | ## Installation 9 | 10 | If [available in Hex](https://hex.pm/docs/publish), the package can be installed 11 | by adding `ex_state` to your list of dependencies in `mix.exs`: 12 | 13 | ```elixir 14 | def deps do 15 | [ 16 | {:ex_state_ecto, "~> 0.3"} 17 | ] 18 | end 19 | ``` 20 | 21 | Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) 22 | and published on [HexDocs](https://hexdocs.pm). Once published, the docs can 23 | be found at [https://hexdocs.pm/ex_state](https://hexdocs.pm/ex_state). 24 | 25 | ## Usage 26 | 27 | ### Without Ecto 28 | 29 | - [Example](test/ex_state/examples/vending_machine_test.exs) 30 | 31 | ### Ecto Setup 32 | 33 | ```elixir 34 | defmodule MyApp.Repo.Migrations.AddWorkflows do 35 | def up do 36 | # Ensure Ecto.UUID support is enabled: 37 | execute("CREATE EXTENSION IF NOT EXISTS pgcrypto") 38 | 39 | ExState.Ecto.Migration.up() 40 | end 41 | 42 | def down do 43 | end 44 | end 45 | ``` 46 | 47 | ```elixir 48 | config :ex_state, repo: MyApp.Repo 49 | ``` 50 | 51 | ### Defining States 52 | 53 | Define the workflow: 54 | 55 | ```elixir 56 | defmodule SaleWorkflow do 57 | use ExState.Definition 58 | 59 | alias MyApp.Repo 60 | 61 | workflow "sale" do 62 | subject :sale, Sale 63 | 64 | participant :seller 65 | participant :buyer 66 | 67 | initial_state :pending 68 | 69 | state :pending do 70 | on :send, :sent 71 | on :cancel, :cancelled 72 | end 73 | 74 | state :sent do 75 | parallel do 76 | step :acknowledge_receipt, participant: :buyer 77 | step :close, participant: :seller 78 | end 79 | 80 | on :cancelled, :cancelled 81 | on_completed :acknowledge_receipt, :receipt_acknowledged 82 | on_completed :close, :closed 83 | end 84 | 85 | state :receipt_acknowledged do 86 | step :close, participant: :seller 87 | on_completed :close, :closed 88 | end 89 | 90 | state :closed 91 | 92 | state :cancelled do 93 | on_entry :update_cancelled_at 94 | end 95 | end 96 | 97 | def guard_transition(:pending, :sent, %{sale: %{address: nil}}) do 98 | {:error, "missing address"} 99 | end 100 | 101 | def guard_transition(_from, _to, _context), do: :ok 102 | 103 | def update_cancelled_at(%{sale: sale}) do 104 | sale 105 | |> Sale.changeset(%{cancelled_at: DateTime.utc_now()}) 106 | |> Repo.update() 107 | end 108 | end 109 | ``` 110 | 111 | Add the workflow association to the subject: 112 | 113 | ```elixir 114 | defmodule Sale do 115 | use Ecto.Schema 116 | use ExState.Ecto.Subject 117 | 118 | import Ecto.Changeset 119 | 120 | schema "sales" do 121 | has_workflow SaleWorkflow 122 | field :product_id, :string 123 | field :cancelled_at, :utc_datetime 124 | end 125 | end 126 | ``` 127 | 128 | Add a `workflow_id` column to the subject table: 129 | 130 | ``` 131 | alter table(:sales) do 132 | add :workflow_id, references(:workflows, type: :uuid) 133 | end 134 | ``` 135 | 136 | ### Transitioning States 137 | 138 | Using `ExState.transition/3`: 139 | 140 | ```elixir 141 | def create_sale(params) do 142 | Multi.new() 143 | |> Multi.insert(:sale, Sale.new(params)) 144 | |> ExState.Ecto.Multi.create(:sale) 145 | |> Repo.transaction() 146 | end 147 | 148 | def cancel_sale(id, user_id: user_id) do 149 | sale = Repo.get(Sale, id) 150 | 151 | ExState.transition(sale, :cancel, user_id: user_id) 152 | end 153 | ``` 154 | 155 | Using `ExState.Execution.transition_maybe/2`: 156 | 157 | ```elixir 158 | sale 159 | |> ExState.create() 160 | |> ExState.Execution.transition_maybe(:send) 161 | |> ExState.persist() 162 | ``` 163 | 164 | Using `ExState.Execution.transition/2`: 165 | 166 | ```elixir 167 | {:ok, execution} = 168 | sale 169 | |> ExState.load() 170 | |> ExState.Execution.transition(:cancelled) 171 | 172 | ExState.persist(execution) 173 | ``` 174 | 175 | Using `ExState.Execution.transition!/2`: 176 | 177 | ```elixir 178 | sale 179 | |> ExState.load() 180 | |> ExState.Execution.transition!(:cancelled) 181 | |> ExState.persist() 182 | ``` 183 | 184 | ### Completing Steps 185 | 186 | ```elixir 187 | def acknowledge_receipt(id, user_id: user_id) do 188 | sale = Repo.get(Sale, id) 189 | 190 | ExState.complete(sale, :acknowledge_receipt, user_id: user_id) 191 | end 192 | ``` 193 | 194 | ### Running Tests 195 | 196 | Setup test database 197 | 198 | ```bash 199 | MIX_ENV=test mix ecto.create 200 | mix test 201 | ``` 202 | 203 | ## TODO 204 | 205 | - Extract `ex_state_core`, and other backend / db packages. 206 | - Multiple workflows per subject. 207 | - Allow configurable primary key / UUID type for usage across different 208 | databases. 209 | - Tracking event history with metadata. 210 | - Add SCXML support 211 | - Define schema for serialization / json API usage / client consumption. 212 | - [Parallel states](https://xstate.js.org/docs/guides/parallel.html#parallel-state-nodes) 213 | - [History states](https://xstate.js.org/docs/guides/history.html#history-state-configuration) 214 | -------------------------------------------------------------------------------- /lib/ex_state/definition/state.ex: -------------------------------------------------------------------------------- 1 | defmodule ExState.Definition.State do 2 | alias ExState.Definition.Step 3 | alias ExState.Definition.Transition 4 | 5 | @type state_type :: :atomic | :compound | :final 6 | 7 | @type t :: %__MODULE__{ 8 | name: String.t(), 9 | type: state_type(), 10 | initial_state: String.t(), 11 | steps: [Step.t()], 12 | ignored_steps: [Step.t()], 13 | repeatable_steps: [String.t()], 14 | transitions: %{required(Transition.event()) => Transition.t()}, 15 | actions: %{required(Transition.event()) => atom()} 16 | } 17 | 18 | defstruct name: nil, 19 | type: :atomic, 20 | initial_state: nil, 21 | steps: [], 22 | ignored_steps: [], 23 | repeatable_steps: [], 24 | transitions: %{}, 25 | actions: %{} 26 | 27 | def transition(state, event) do 28 | Map.get(state.transitions, event) 29 | end 30 | 31 | def transitions(state) do 32 | state.transitions 33 | |> Map.values() 34 | |> Enum.reduce([], fn transition, events -> 35 | case transition.event do 36 | {:completed, _step} -> events 37 | {:decision, _step, _decision} -> events 38 | event_name when is_atom(event_name) -> [%{event: event_name, state: state.name} | events] 39 | end 40 | end) 41 | end 42 | 43 | def actions(state, event) do 44 | Map.get(state.actions, event, []) 45 | end 46 | 47 | def add_transition(state, transition) do 48 | %__MODULE__{state | transitions: Map.put(state.transitions, transition.event, transition)} 49 | end 50 | 51 | def add_action(state, event, action) do 52 | %__MODULE__{ 53 | state 54 | | actions: Map.update(state.actions, event, [action], fn actions -> [action | actions] end) 55 | } 56 | end 57 | 58 | def add_step(state, step) do 59 | add_step(state, step, Enum.count(state.steps) + 1) 60 | end 61 | 62 | def add_step(state, step, order) do 63 | %__MODULE__{state | steps: [Step.order(step, order) | state.steps]} 64 | end 65 | 66 | def add_parallel_steps(state, steps) do 67 | order = Enum.count(state.steps) + 1 68 | 69 | Enum.reduce(steps, state, fn step, state -> 70 | add_step(state, step, order) 71 | end) 72 | end 73 | 74 | def add_repeatable_step(state, step) do 75 | %__MODULE__{state | repeatable_steps: [step | state.repeatable_steps]} 76 | end 77 | 78 | def repeatable?(state, step_name) do 79 | if Enum.member?(state.repeatable_steps, step_name) do 80 | case Enum.find(state.steps ++ state.ignored_steps, fn step -> step.name == step_name end) do 81 | nil -> 82 | true 83 | 84 | step -> 85 | step.complete? 86 | end 87 | else 88 | false 89 | end 90 | end 91 | 92 | def filter_steps(state, filter) do 93 | {ignored, steps} = 94 | Enum.reduce(state.steps, {[], []}, fn step, {ignored, steps} -> 95 | if filter.(step) do 96 | {ignored, [step | steps]} 97 | else 98 | {[step | ignored], steps} 99 | end 100 | end) 101 | 102 | %__MODULE__{state | steps: Enum.reverse(steps), ignored_steps: Enum.reverse(ignored)} 103 | end 104 | 105 | def next_steps(state) do 106 | state.steps 107 | |> Enum.filter(fn step -> !step.complete? end) 108 | |> Enum.sort_by(fn step -> step.order end) 109 | |> Enum.chunk_by(fn step -> step.order end) 110 | |> List.first() 111 | end 112 | 113 | def complete_step(state, name, decision \\ nil) 114 | 115 | def complete_step(state, id, decision) when is_atom(id) do 116 | complete_step(state, Step.name(id), decision) 117 | end 118 | 119 | def complete_step(state, name, decision) when is_bitstring(name) do 120 | case next_steps(state) do 121 | nil -> 122 | if repeatable?(state, name) do 123 | {:ok, state} 124 | else 125 | {:error, [], state} 126 | end 127 | 128 | next_steps -> 129 | case Enum.any?(next_steps, fn step -> step.name == name end) do 130 | true -> 131 | {:ok, put_completed_step(state, name, decision)} 132 | 133 | false -> 134 | if repeatable?(state, name) do 135 | {:ok, state} 136 | else 137 | {:error, next_steps, state} 138 | end 139 | end 140 | end 141 | end 142 | 143 | def put_completed_step(state, name, decision \\ nil) when is_bitstring(name) do 144 | steps = 145 | Enum.map(state.steps, fn 146 | %Step{name: ^name} = step -> Step.complete(step, decision) 147 | step -> step 148 | end) 149 | 150 | %__MODULE__{state | steps: steps} 151 | end 152 | 153 | def final?(%__MODULE__{type: :final}), do: true 154 | def final?(%__MODULE__{}), do: false 155 | 156 | def child?(%__MODULE__{} = state, %__MODULE__{} = child_maybe) do 157 | combine(drop_last(child_maybe.name)) == state.name 158 | end 159 | 160 | def sibling?(%__MODULE__{} = state, %__MODULE__{} = sibling_maybe) do 161 | combine(drop_last(state.name)) == combine(drop_last(sibling_maybe.name)) 162 | end 163 | 164 | def name(id) when is_atom(id), do: Atom.to_string(id) 165 | def name(id) when is_bitstring(id), do: id 166 | 167 | # The atom may not exist due to being converted to string at compile time. 168 | # Should be safe to use to_atom here since this API shouldn't be 169 | # exposed to external input. 170 | def id(state), do: state.name |> last() |> String.to_atom() 171 | 172 | def resolve(nil, next) when is_atom(next), do: next 173 | def resolve(current, next) when is_list(next), do: Enum.map(next, &resolve(current, &1)) 174 | def resolve(current, :_), do: current 175 | 176 | def resolve(current, {:<, next}) when is_atom(next) do 177 | current 178 | |> parent() 179 | |> sibling(next) 180 | end 181 | 182 | def resolve(current, next) when is_atom(next) do 183 | current 184 | |> sibling(next) 185 | end 186 | 187 | def parent(nil), do: nil 188 | 189 | def parent(state) do 190 | state 191 | |> split() 192 | |> drop_last() 193 | |> combine() 194 | end 195 | 196 | def child(nil, state), do: state 197 | 198 | def child(current, state) do 199 | current 200 | |> split() 201 | |> append(state) 202 | |> combine() 203 | end 204 | 205 | def sibling(nil, state), do: state 206 | 207 | def sibling(current, state) do 208 | current 209 | |> split() 210 | |> drop_last() 211 | |> append(state) 212 | |> combine() 213 | end 214 | 215 | def drop_last(name) when is_bitstring(name), do: split(name) |> drop_last() 216 | def drop_last(states) when is_list(states), do: Enum.drop(states, -1) 217 | def append(states, state), do: List.insert_at(states, -1, state) 218 | def split(state), do: String.split(state, ".") 219 | def last(state), do: split(state) |> List.last() 220 | def combine(states), do: Enum.join(states, ".") 221 | end 222 | -------------------------------------------------------------------------------- /lib/ex_state/ecto/query.ex: -------------------------------------------------------------------------------- 1 | defmodule ExState.Ecto.Query do 2 | @moduledoc """ 3 | `ExState.Ecto.Query` provides functions for querying workflow state in the 4 | database through Ecto. 5 | """ 6 | 7 | import Ecto.Query 8 | 9 | @doc """ 10 | where_state/2 takes a subject query and state and filters based on workflows that are in the 11 | exact state that is passed. Nested states can be passed as a list of states and will be converted 12 | to a valid state identifier in the query. 13 | 14 | Pass the state as the keyword list `not: state` in order to query the inverse. 15 | 16 | Examples: 17 | 18 | investment #=> %Investment{workflow: %Workflow{state: "subscribing.confirming_options"}} 19 | 20 | Investment 21 | |> where_state("subscribing.confirming_options") 22 | |> Repo.all() #=> [investment] 23 | 24 | Investment 25 | |> where_state([:subscribing, :confirming_options]) 26 | |> Repo.all() #=> [investment] 27 | 28 | Investment 29 | |> where_state(["subscribing", "confirming_options"]) 30 | |> Repo.all() #=> [investment] 31 | 32 | Investment 33 | |> where_state(:subscribing) 34 | |> Repo.all() #=> [] 35 | 36 | Investment 37 | |> where_state(not: [:subscribing, :confirming_suitability]) 38 | |> Repo.all() #=> [investment] 39 | 40 | Investment 41 | |> where_state(not: [:subscribing, :confirming_options]) 42 | |> Repo.all() #=> [] 43 | """ 44 | def where_state(subject_query, not: state) do 45 | subject_query 46 | |> join_workflow_maybe() 47 | |> where([workflow: workflow], workflow.state != ^to_state_id(state)) 48 | end 49 | 50 | def where_state(subject_query, state) do 51 | subject_query 52 | |> join_workflow_maybe() 53 | |> where([workflow: workflow], workflow.state == ^to_state_id(state)) 54 | end 55 | 56 | @doc """ 57 | where_state_in/2 takes a subject query and a list of states and filters based on workflows that 58 | are in one of the exact states that are passed. Nested states can be passed as a list of states 59 | and will be converted to a valid state identifier in the query. 60 | 61 | Pass the state as the keyword list `not: state` in order to query the inverse. 62 | 63 | Examples: 64 | 65 | investment1 #=> %Investment{workflow: %Workflow{state: "subscribing.confirming_options"}} 66 | investment2 #=> %Investment{workflow: %Workflow{state: "executed"}} 67 | 68 | Investment 69 | |> where_state_in([ 70 | [:subscribing, :confirming_options], 71 | :executed 72 | ]) 73 | |> Repo.all() #=> [investment1, investment2] 74 | 75 | Investment 76 | |> where_state_in(["subscribing.confirming_options"]) 77 | |> Repo.all() #=> [investment1] 78 | 79 | Investment 80 | |> where_state_in([:subscribing]) 81 | |> Repo.all() #=> [] 82 | 83 | Investment 84 | |> where_state_in(not: [[:subscribing, :confirming_options]]) 85 | |> Repo.all() #=> [investment2] 86 | 87 | Investment 88 | |> where_state_in(not: [:subscribing]) 89 | |> Repo.all() #=> [investment1, investment2] 90 | """ 91 | def where_state_in(subject_query, not: states) do 92 | subject_query 93 | |> join_workflow_maybe() 94 | |> where([workflow: workflow], workflow.state not in ^Enum.map(states, &to_state_id/1)) 95 | end 96 | 97 | def where_state_in(subject_query, states) do 98 | subject_query 99 | |> join_workflow_maybe() 100 | |> where([workflow: workflow], workflow.state in ^Enum.map(states, &to_state_id/1)) 101 | end 102 | 103 | @doc """ 104 | where_any_state/2 takes a subject query and a state and filters based on workflows that are equal 105 | to or in a child state of the given state. Nested states can be passed as a list of states 106 | and will be converted to a valid state identifier in the query. 107 | 108 | Examples: 109 | 110 | investment #=> %Investment{workflow: %Workflow{state: "subscribing.confirming_options"}} 111 | 112 | Investment 113 | |> where_any_state(:subscribing) 114 | |> Repo.all() #=> [investment] 115 | 116 | Investment 117 | |> where_any_state("subscribing") 118 | |> Repo.all() #=> [investment] 119 | 120 | Investment 121 | |> where_any_state([:subscribing, :confirming_options]) 122 | |> Repo.all() #=> [investment] 123 | 124 | Investment 125 | |> where_any_state(:resubmitting) 126 | |> Repo.all() #=> [] 127 | """ 128 | def where_any_state(subject_query, state) do 129 | state_id = to_state_id(state) 130 | 131 | subject_query 132 | |> join_workflow_maybe() 133 | |> where( 134 | [workflow: workflow], 135 | workflow.state == ^state_id or ilike(workflow.state, ^"#{state_id}.%") 136 | ) 137 | end 138 | 139 | def where_step_complete(q, s) when is_atom(s), 140 | do: where_step_complete(q, Atom.to_string(s)) 141 | 142 | def where_step_complete(subject_query, step_name) do 143 | subject_query 144 | |> join_workflow_maybe() 145 | |> join_workflow_steps_maybe() 146 | |> where([workflow_step: step], step.name == ^step_name and step.complete?) 147 | end 148 | 149 | def to_state_id(states) when is_list(states) do 150 | Enum.map(states, &to_state_id/1) |> Enum.join(".") 151 | end 152 | 153 | def to_state_id(state) when is_atom(state), do: Atom.to_string(state) 154 | def to_state_id(state) when is_bitstring(state), do: state 155 | 156 | def to_state_list(state) when is_bitstring(state) do 157 | String.split(state, ".") |> Enum.map(&String.to_atom/1) 158 | end 159 | 160 | def to_step_name(step) when is_atom(step), do: Atom.to_string(step) 161 | def to_step_name(step) when is_bitstring(step), do: step 162 | 163 | defp join_workflow_maybe(subject_query) do 164 | if has_named_binding?(subject_query, :workflow) do 165 | subject_query 166 | else 167 | join(subject_query, :inner, [sub], w in assoc(sub, :workflow), as: :workflow) 168 | end 169 | end 170 | 171 | defp join_workflow_steps_maybe(subject_query) do 172 | if has_named_binding?(subject_query, :workflow_step) do 173 | subject_query 174 | else 175 | join(subject_query, :inner, [workflow: w], s in assoc(w, :steps), as: :workflow_step) 176 | end 177 | end 178 | end 179 | -------------------------------------------------------------------------------- /lib/ex_state/definition/compiler.ex: -------------------------------------------------------------------------------- 1 | defmodule ExState.Definition.Compiler do 2 | alias ExState.Definition.{ 3 | Chart, 4 | State, 5 | Step, 6 | Transition 7 | } 8 | 9 | defmodule Env do 10 | defstruct chart: nil, 11 | macro_env: nil, 12 | virtual_states: %{} 13 | end 14 | 15 | def compile(name, body, macro_env) do 16 | env = do_compile(name, body, macro_env) 17 | chart = expand_subject_alias(env.chart, macro_env) 18 | Macro.escape(chart) 19 | end 20 | 21 | defp do_compile(name, body, env) do 22 | chart = %Chart{name: name} 23 | env = %Env{chart: chart, macro_env: env} 24 | compile_chart(env, body) 25 | end 26 | 27 | defp compile_chart(%Env{} = env, do: body) do 28 | compile_chart(env, body) 29 | end 30 | 31 | defp compile_chart(%Env{} = env, {:__block__, _, body}) do 32 | compile_chart(env, body) 33 | end 34 | 35 | defp compile_chart(%Env{} = env, body) when is_list(body) do 36 | Enum.reduce(body, env, fn next, acc -> 37 | compile_chart(acc, next) 38 | end) 39 | end 40 | 41 | defp compile_chart(%Env{chart: chart} = env, {:subject, _, [name, queryable]}) do 42 | %Env{env | chart: %Chart{chart | subject: {name, queryable}}} 43 | end 44 | 45 | defp compile_chart(%Env{chart: chart} = env, {:participant, _, [name]}) do 46 | %Env{env | chart: Chart.put_participant(chart, name)} 47 | end 48 | 49 | defp compile_chart(%Env{chart: chart} = env, {:initial_state, _, [id]}) do 50 | %Env{env | chart: %Chart{chart | initial_state: State.name(id)}} 51 | end 52 | 53 | defp compile_chart(%Env{virtual_states: virtual_states} = env, {:virtual, _, [name, body]}) do 54 | %Env{env | virtual_states: Map.put(virtual_states, name, body)} 55 | end 56 | 57 | defp compile_chart(%Env{} = env, {:state, _, [id]}) do 58 | compile_chart(env, {:state, [], [id, []]}) 59 | end 60 | 61 | defp compile_chart(%Env{chart: %Chart{states: states} = chart} = env, {:state, _, [id, body]}) do 62 | more_states = compile_state(env, id, body) 63 | 64 | merged_states = 65 | Enum.reduce(more_states, states, fn next, acc -> 66 | Map.put(acc, next.name, next) 67 | end) 68 | 69 | %Env{env | chart: %Chart{chart | states: merged_states}} 70 | end 71 | 72 | defp compile_state(env, id, body) when is_atom(id) do 73 | compile_state(env, nil, id, body) 74 | end 75 | 76 | defp compile_state(env, current, id, body) when is_atom(id) do 77 | compile_state(env, current, State.name(id), body) 78 | end 79 | 80 | defp compile_state(env, current, name, body) when is_bitstring(name) do 81 | full_name = State.child(current, name) 82 | compile_state(env, full_name, [%State{name: full_name}], body) 83 | end 84 | 85 | defp compile_state(env, current, states, do: body) do 86 | compile_state(env, current, states, body) 87 | end 88 | 89 | defp compile_state(env, current, states, {:__block__, _, body}) do 90 | compile_state(env, current, states, body) 91 | end 92 | 93 | defp compile_state(env, current, [%State{name: name} | _rest] = states, body) 94 | when is_list(body) do 95 | Enum.reduce(body, states, fn 96 | {:state, _, _} = next, [%State{name: ^name} = state | rest] -> 97 | acc = [%State{state | type: :compound} | rest] 98 | compile_state(env, current, acc, next) 99 | 100 | next, acc -> 101 | compile_state(env, current, acc, next) 102 | end) 103 | end 104 | 105 | defp compile_state(env, current, states, {:using, _, [id]}) do 106 | body = Map.get(env.virtual_states, id, []) 107 | compile_state(env, current, states, body) 108 | end 109 | 110 | defp compile_state(env, current, states, {:state, _, [id, body]}) do 111 | next_states = compile_state(env, current, id, body) 112 | next_states ++ states 113 | end 114 | 115 | defp compile_state(_env, current, [%State{} = state | rest], {:initial_state, _, [id]}) do 116 | [%State{state | initial_state: State.child(current, id)} | rest] 117 | end 118 | 119 | defp compile_state(_env, _current, [state | rest], {:final, _, nil}) do 120 | [%State{state | type: :final} | rest] 121 | end 122 | 123 | defp compile_state( 124 | _env, 125 | _current, 126 | [state | rest], 127 | {:parallel, _, [[do: {:__block__, _, body}]]} 128 | ) do 129 | steps = 130 | Enum.map(body, fn 131 | {:step, _, [id]} -> Step.new(id, nil) 132 | {:step, _, [id, opts]} -> Step.new(id, Keyword.get(opts, :participant)) 133 | end) 134 | 135 | [State.add_parallel_steps(state, steps) | rest] 136 | end 137 | 138 | defp compile_state(env, current, states, {:step, _, [id]}) do 139 | compile_state(env, current, states, {:step, [], [id, []]}) 140 | end 141 | 142 | defp compile_state(_env, _current, [state | rest], {:step, _, [id, opts]}) do 143 | repeatable = Keyword.get(opts, :repeatable) 144 | step = Step.new(id, Keyword.get(opts, :participant)) 145 | state = State.add_step(state, step) 146 | 147 | state = 148 | if repeatable do 149 | State.add_repeatable_step(state, Step.name(id)) 150 | else 151 | state 152 | end 153 | 154 | [state | rest] 155 | end 156 | 157 | defp compile_state(_env, _current, [state | rest], {:repeatable, _, [step_id]}) do 158 | [State.add_repeatable_step(state, Step.name(step_id)) | rest] 159 | end 160 | 161 | defp compile_state(_env, _current, [state | rest], {:on_entry, _, [action]}) do 162 | [State.add_action(state, :entry, action) | rest] 163 | end 164 | 165 | defp compile_state(_env, _current, [state | rest], {:on_exit, _, [action]}) do 166 | [State.add_action(state, :exit, action) | rest] 167 | end 168 | 169 | defp compile_state(env, current, states, {:on_completed, _, [step, target]}) do 170 | compile_state(env, current, states, {:on, [], [{:completed, step}, target]}) 171 | end 172 | 173 | defp compile_state(env, current, states, {:on_completed, _, [step, target, options]}) do 174 | compile_state(env, current, states, {:on, [], [{:completed, step}, target, options]}) 175 | end 176 | 177 | defp compile_state(env, current, states, {:on_decision, _, [step, decision, target]}) do 178 | compile_state(env, current, states, {:on, [], [{:decision, step, decision}, target]}) 179 | end 180 | 181 | defp compile_state( 182 | env, 183 | current, 184 | states, 185 | {:on_decision, _, [step, decision, target, options]} 186 | ) do 187 | compile_state( 188 | env, 189 | current, 190 | states, 191 | {:on, [], [{:decision, step, decision}, target, options]} 192 | ) 193 | end 194 | 195 | defp compile_state(env, current, states, {:on_no_steps, _, [target]}) do 196 | compile_state(env, current, states, {:on_no_steps, [], [target, []]}) 197 | end 198 | 199 | defp compile_state(env, current, states, {:on_no_steps, _, [target, options]}) do 200 | compile_state(env, current, states, {:on, [], [:__no_steps__, target, options]}) 201 | end 202 | 203 | defp compile_state(env, current, states, {:on_final, _, [target]}) do 204 | compile_state(env, current, states, {:on_final, [], [target, []]}) 205 | end 206 | 207 | defp compile_state(env, current, states, {:on_final, _, [target, options]}) do 208 | compile_state(env, current, states, {:on, [], [:__final__, target, options]}) 209 | end 210 | 211 | defp compile_state(env, current, states, {:on, _, [id, target]}) do 212 | compile_state(env, current, states, {:on, [], [id, target, []]}) 213 | end 214 | 215 | defp compile_state(_env, current, [state | rest], {:on, _, [event, target, opts]}) do 216 | transition = Transition.new(event, State.resolve(current, target), opts) 217 | [State.add_transition(state, transition) | rest] 218 | end 219 | 220 | defp compile_state(_env, _current, states, _) when is_list(states) do 221 | states 222 | end 223 | 224 | defp expand_subject_alias(%Chart{subject: {name, queryable}} = chart, env) do 225 | %Chart{chart | subject: {name, Macro.expand(queryable, env)}} 226 | end 227 | 228 | defp expand_subject_alias(chart, _env) do 229 | chart 230 | end 231 | end 232 | -------------------------------------------------------------------------------- /lib/ex_state.ex: -------------------------------------------------------------------------------- 1 | defmodule ExState do 2 | @moduledoc """ 3 | `ExState` loads and persists workflow execution to a database through Ecto. 4 | 5 | The `ExState.Execution` is built through the subject's `:workflow` 6 | association. 7 | 8 | ## Setup 9 | 10 | defmodule ShipmentWorkflow do 11 | use ExState.Definition 12 | 13 | workflow "shipment" do 14 | subject :shipment, Shipment 15 | 16 | initial_state :preparing 17 | 18 | state :preparing do 19 | state :packing do 20 | on :packed, :sealing 21 | end 22 | 23 | state :sealing do 24 | on :unpack, :packing 25 | on :sealed, :sealed 26 | end 27 | 28 | state :sealed do 29 | final 30 | end 31 | 32 | on_final :shipping 33 | end 34 | 35 | state :shipping do 36 | on :shipped, :in_transit 37 | end 38 | 39 | state :in_transit do 40 | on :arrival, :arrived 41 | end 42 | 43 | state :arrived od 44 | on :accepted, :complete 45 | on :return, :returning 46 | end 47 | 48 | state :returning do 49 | on :arrival, :returned 50 | end 51 | 52 | state :returned do 53 | on :replace, :preparing 54 | end 55 | 56 | state :complete do 57 | final 58 | end 59 | end 60 | end 61 | 62 | defmodule Shipment do 63 | use Ecto.Schema 64 | use ExState.Ecto.Subject 65 | 66 | schema "shipments" do 67 | has_workflow ShipmentWorkflow 68 | end 69 | end 70 | 71 | ## Creating 72 | 73 | sale = %Sale{id: 1} 74 | 75 | execution = ExState.create(sale) #=> %ExState.Execution{} 76 | 77 | ## Updating 78 | 79 | sale = %Sale{id: 1} 80 | 81 | {:ok, sale} = 82 | sale 83 | |> ExState.load() 84 | |> ExState.Execution.transition!(:packed) 85 | |> ExState.Execution.transition!(:sealed) 86 | |> ExState.persist() 87 | 88 | sale.workflow.state #=> "shipping" 89 | 90 | {:error, reason} = ExState.transition(sale, :return) 91 | reason #=> "no transition from state shipping for event :return" 92 | """ 93 | 94 | import Ecto.Query 95 | 96 | alias ExState.Execution 97 | alias ExState.Result 98 | alias ExState.Ecto.Workflow 99 | alias ExState.Ecto.WorkflowStep 100 | alias ExState.Ecto.Subject 101 | alias Ecto.Multi 102 | alias Ecto.Changeset 103 | 104 | defp repo do 105 | Application.fetch_env!(:ex_state, :repo) 106 | end 107 | 108 | @spec create(struct()) :: {:ok, Execution.t()} | {:error, any()} 109 | def create(subject) do 110 | create_multi(subject) 111 | |> repo().transaction() 112 | |> Result.Multi.extract(:subject) 113 | |> Result.map(&load/1) 114 | end 115 | 116 | @spec create!(struct()) :: Execution.t() 117 | def create!(subject) do 118 | create(subject) |> Result.get() 119 | end 120 | 121 | @spec create_multi(struct()) :: Multi.t() 122 | def create_multi(%queryable{} = subject) do 123 | Multi.new() 124 | |> Multi.insert(:workflow, create_changeset(subject)) 125 | |> Multi.run(:subject, fn _repo, %{workflow: workflow} -> 126 | subject 127 | |> queryable.changeset(%{workflow_id: workflow.id}) 128 | |> repo().update() 129 | |> Result.map(&Subject.put_workflow(&1, workflow)) 130 | end) 131 | end 132 | 133 | @spec load(struct()) :: Execution.t() | nil 134 | def load(subject) do 135 | with workflow when not is_nil(workflow) <- get(subject), 136 | definition <- Subject.workflow_definition(subject), 137 | execution <- Execution.continue(definition, workflow.state), 138 | execution <- Execution.put_subject(execution, subject), 139 | execution <- with_completed_steps(execution, workflow), 140 | execution <- Execution.with_meta(execution, :workflow, workflow) do 141 | execution 142 | end 143 | end 144 | 145 | defp get(subject) do 146 | subject 147 | |> Ecto.assoc(Subject.workflow_association(subject)) 148 | |> preload(:steps) 149 | |> repo().one() 150 | end 151 | 152 | defp with_completed_steps(execution, workflow) do 153 | completed_steps = Workflow.completed_steps(workflow) 154 | 155 | Enum.reduce(completed_steps, execution, fn step, execution -> 156 | Execution.with_completed(execution, step.state, step.name, step.decision) 157 | end) 158 | end 159 | 160 | @spec transition(struct(), any(), keyword()) :: {:ok, struct()} | {:error, any()} 161 | def transition(subject, event, opts \\ []) do 162 | load(subject) 163 | |> Execution.with_meta(:opts, opts) 164 | |> Execution.transition(event) 165 | |> map_execution_error() 166 | |> Result.flat_map(&persist/1) 167 | end 168 | 169 | @spec complete(struct(), any(), keyword()) :: {:ok, struct()} | {:error, any()} 170 | def complete(subject, step_id, opts \\ []) do 171 | load(subject) 172 | |> Execution.with_meta(:opts, opts) 173 | |> Execution.complete(step_id) 174 | |> map_execution_error() 175 | |> Result.flat_map(&persist/1) 176 | end 177 | 178 | @spec decision(struct(), any(), any(), keyword()) :: {:ok, struct()} | {:error, any()} 179 | def decision(subject, step_id, decision, opts \\ []) do 180 | load(subject) 181 | |> Execution.with_meta(:opts, opts) 182 | |> Execution.decision(step_id, decision) 183 | |> map_execution_error() 184 | |> Result.flat_map(&persist/1) 185 | end 186 | 187 | defp map_execution_error({:error, reason, _execution}), do: {:error, reason} 188 | defp map_execution_error(result), do: result 189 | 190 | @spec persist(Execution.t()) :: {:ok, struct()} | {:error, any()} 191 | def persist(execution) do 192 | actions_multi = 193 | Enum.reduce(Enum.reverse(execution.actions), Multi.new(), fn action, multi -> 194 | Multi.run(multi, action, fn _, _ -> 195 | case Execution.execute_action(execution, action) do 196 | {:ok, execution, result} -> {:ok, {execution, result}} 197 | e -> e 198 | end 199 | end) 200 | end) 201 | 202 | Multi.new() 203 | |> Multi.run(:workflow, fn _repo, _ -> 204 | workflow = Map.fetch!(execution.meta, :workflow) 205 | opts = Map.get(execution.meta, :opts, []) 206 | update_workflow(workflow, execution, opts) 207 | end) 208 | |> Multi.append(actions_multi) 209 | |> repo().transaction() 210 | |> case do 211 | {:ok, %{workflow: workflow} = results} -> 212 | actions_multi 213 | |> Multi.to_list() 214 | |> List.last() 215 | |> case do 216 | nil -> 217 | {:ok, Subject.put_workflow(Execution.get_subject(execution), workflow)} 218 | 219 | {action, _} -> 220 | case Map.get(results, action) do 221 | nil -> 222 | {:ok, Subject.put_workflow(Execution.get_subject(execution), workflow)} 223 | 224 | {execution, _} -> 225 | {:ok, Subject.put_workflow(Execution.get_subject(execution), workflow)} 226 | end 227 | end 228 | 229 | {:error, _, reason, _} -> 230 | {:error, reason} 231 | end 232 | end 233 | 234 | defp update_workflow(workflow, execution, opts) do 235 | workflow 236 | |> update_changeset(execution, opts) 237 | |> repo().update() 238 | end 239 | 240 | defp create_changeset(subject) do 241 | params = 242 | Subject.workflow_definition(subject) 243 | |> Execution.new() 244 | |> Execution.put_subject(subject) 245 | |> Execution.dump() 246 | 247 | Workflow.new(params) 248 | |> Changeset.cast_assoc(:steps, 249 | required: true, 250 | with: fn step, params -> 251 | step 252 | |> WorkflowStep.changeset(params) 253 | end 254 | ) 255 | end 256 | 257 | defp update_changeset(workflow, execution, opts) do 258 | params = 259 | execution 260 | |> Execution.dump() 261 | |> put_existing_step_ids(workflow) 262 | 263 | workflow 264 | |> Workflow.changeset(params) 265 | |> Changeset.cast_assoc(:steps, 266 | required: true, 267 | with: fn step, params -> 268 | step 269 | |> WorkflowStep.changeset(params) 270 | |> WorkflowStep.put_completion(Enum.into(opts, %{})) 271 | end 272 | ) 273 | end 274 | 275 | defp put_existing_step_ids(params, workflow) do 276 | Map.update(params, :steps, [], fn steps -> 277 | Enum.map(steps, fn step -> put_existing_step_id(step, workflow.steps) end) 278 | end) 279 | end 280 | 281 | defp put_existing_step_id(step, existing_steps) do 282 | Enum.find(existing_steps, fn existing_step -> 283 | step.state == existing_step.state and step.name == existing_step.name 284 | end) 285 | |> case do 286 | nil -> 287 | step 288 | 289 | existing_step -> 290 | Map.put(step, :id, existing_step.id) 291 | end 292 | end 293 | end 294 | -------------------------------------------------------------------------------- /lib/ex_state/definition.ex: -------------------------------------------------------------------------------- 1 | defmodule ExState.Definition do 2 | @moduledoc """ 3 | `ExState.Definition` provides macros to define a workflow state chart. 4 | 5 | A workflow is defined with a name: 6 | 7 | workflow "make_deal" do 8 | #... 9 | end 10 | 11 | ## Subject 12 | 13 | The subject of the workflow is used to associate the workflow for lookup 14 | in the database. The subject is added to the context under the defined key and 15 | can be used in callbacks `use_step?/2`, and `guard_transition/3`. Subject 16 | names and types are defined using the `subject` keyword: 17 | 18 | subject :deal, Deal 19 | 20 | ## Initial State 21 | 22 | A workflow must have an initial state: 23 | 24 | initial_state :pending 25 | 26 | This state must be defined using a seperate state definition. 27 | 28 | ## States 29 | 30 | States have a name, and optional sub-states, steps, and transitions: 31 | 32 | state :pending do 33 | initial_state :preparing 34 | 35 | state :preparing do 36 | on :review, :reviewing 37 | end 38 | 39 | state :reviewing do 40 | on :cancel, :cancelled 41 | end 42 | end 43 | 44 | state :cancelled 45 | 46 | Transitions may be a list of targets, in which case the first target state 47 | which is allowed by `guard_transition/3` will be used. 48 | 49 | state :pending do 50 | initial_state :preparing 51 | 52 | state :preparing do 53 | on :prepared, [:reviewing, :sending] 54 | end 55 | 56 | state :reviewing do 57 | on :cancel, :cancelled 58 | end 59 | 60 | state :sending do 61 | on :send, :sent 62 | end 63 | end 64 | 65 | def guard_transition(shipment, :preparing, :reviewing) do 66 | if shipment.requires_review? do 67 | :ok 68 | else 69 | {:error, "no review required"} 70 | end 71 | end 72 | 73 | def guard_transition(shipment, :preparing, :sending) do 74 | if shipment.requires_review? do 75 | {:error, "review required"} 76 | else 77 | :ok 78 | end 79 | end 80 | 81 | def guard_transition(_, _, ), do: :ok 82 | 83 | Transitions may also use the null event, which occurs immediately on entering 84 | a state. This is useful determining the initial state dynamically. 85 | 86 | state :unknown do 87 | on :_, [:a, :b] 88 | end 89 | 90 | state :a 91 | state :b 92 | 93 | def guard_transition(order, :unknown, :a), do 94 | if order.use_a?, do: :ok, else: {:error, :use_b} 95 | end 96 | 97 | ## Steps 98 | 99 | Steps must be completed in order of definition: 100 | 101 | state :preparing do 102 | step :read 103 | step :sign 104 | step :confirm 105 | end 106 | 107 | Steps can be defined in parallel, meaning any step from the block can be 108 | completed independent of order: 109 | 110 | state :preparing do 111 | parallel do 112 | step :read 113 | step :sign 114 | step :confirm 115 | end 116 | end 117 | 118 | Step completed events can be handled to transition to new states: 119 | 120 | state :preparing do 121 | step :read 122 | step :sign 123 | step :confirm 124 | on_completed :confirm, :done 125 | end 126 | 127 | state :done 128 | 129 | States can be ignored on a subject basis through `use_step/2`: 130 | 131 | def use_step(:sign, %{deal: deal}) do 132 | deal.requires_signature? 133 | end 134 | 135 | def use_step(_, _), do: true 136 | 137 | ## Virtual States 138 | 139 | States definitions can be reused through virtual states: 140 | 141 | virtual :completion_states do 142 | state :working do 143 | step :read 144 | step :sign 145 | step :confirm 146 | end 147 | end 148 | 149 | state :completing_a do 150 | using :completion_states 151 | on_completed :confirm, :completing_b 152 | end 153 | 154 | state :completing_b do 155 | using :completion_states 156 | on_completed :confirm, :done 157 | end 158 | 159 | state :done 160 | 161 | ## Decisions 162 | 163 | Decisions are steps that have defined options. The selection of an 164 | option can be used to determine state transitions: 165 | 166 | state :preparing do 167 | step :read 168 | step :review_terms 169 | on_decision :review_terms, :accept, :signing 170 | on_decision :review_terms, :reject, :rejected 171 | end 172 | 173 | state :signing do 174 | step :sign 175 | on_completed :sign, :done 176 | end 177 | 178 | state :rejected 179 | state :done 180 | 181 | ## Transitions 182 | 183 | By default, transitions reference sibling states: 184 | 185 | state :one do 186 | on :done, :two 187 | end 188 | 189 | state :two 190 | 191 | Transitions can reference states one level up the heirarchy (a sibling of the 192 | parent state) by using `{:<, :state}`, in the following form: 193 | 194 | state :one do 195 | state :a do 196 | on :done, {:<, :two} 197 | end 198 | end 199 | 200 | state :two 201 | 202 | Transitions can also explicitly denote legal events in the current state 203 | using `:_`. The following adds a transition to the current state: 204 | 205 | state :one do 206 | on :done, :two 207 | end 208 | 209 | state :two do 210 | on :done, :_ 211 | end 212 | 213 | Transitions to the current state will reset completed steps in the current 214 | state by default. Step state can be preserved by using the `reset: false` 215 | option. 216 | 217 | state :one do 218 | step :a 219 | on :done, :two 220 | on :retry, :_, reset: true 221 | end 222 | 223 | state :two do 224 | step :b 225 | on :done, :_, reset: false 226 | end 227 | 228 | ## Guards 229 | 230 | Guards validate that certain dynamic conditions are met in order to 231 | allow state transitions: 232 | 233 | def guard_transition(:one, :two, %{note: note}) do 234 | if length(note.text) > 5 do 235 | :ok 236 | else 237 | {:error, "Text must be greater than 5 characters long"} 238 | end 239 | end 240 | 241 | def guard_transition(_, _, _), do: :ok 242 | 243 | Execution will stop the state transition if `{:error, reason}` is returned 244 | from the guard, and will allow the transition if `:ok` is returned. 245 | 246 | ## Actions 247 | 248 | Actions are side effects that happen on events. Events can be 249 | transitions, entering a state, or exiting a state. 250 | 251 | state :one do 252 | on_entry :send_notification 253 | on_entry :log_activity 254 | on :done, :two, action: [:update_done_at] 255 | end 256 | 257 | state :two do 258 | step :send_something 259 | end 260 | 261 | def update_done_at(%{note: note} = context) do 262 | {:updated, Map.put(context, :note, %{note | done_at: now()})} 263 | end 264 | 265 | Actions can return a `{:updated, context}` tuple to add the updated 266 | context to the execution state. A default `Execution.execute_actions/1` 267 | function is provided which executes triggered actions in a fire-and-forget 268 | fashion. See `ExState.persist/1` for an example of transactionally 269 | executing actions. 270 | 271 | Actions should also not explicity guard state transitions. Guards should use 272 | `guard_transition/3`. 273 | """ 274 | 275 | alias ExState.Execution 276 | alias ExState.Definition.Chart 277 | 278 | @type state() :: atom() 279 | @type step() :: atom() 280 | @type context() :: map() 281 | @callback use_step?(step(), context()) :: boolean() 282 | @callback guard_transition(state(), state(), context()) :: :ok | {:error, any()} 283 | @optional_callbacks use_step?: 2, guard_transition: 3 284 | 285 | defmacro __using__(_) do 286 | quote do 287 | @behaviour unquote(__MODULE__) 288 | 289 | require ExState.Definition.Compiler 290 | 291 | import unquote(__MODULE__), only: [workflow: 2] 292 | end 293 | end 294 | 295 | defmacro workflow(name, body) do 296 | chart = ExState.Definition.Compiler.compile(name, body, __CALLER__) 297 | 298 | quote do 299 | Module.put_attribute(__MODULE__, :chart, unquote(chart)) 300 | 301 | def definition, do: @chart 302 | def name, do: @chart.name 303 | def subject, do: @chart.subject 304 | def initial_state, do: @chart.initial_state 305 | def describe, do: Chart.describe(@chart) 306 | def states, do: Chart.states(@chart) 307 | def steps, do: Chart.steps(@chart) 308 | def events, do: Chart.events(@chart) 309 | def state(id), do: Chart.state(@chart, id) 310 | def state(id1, id2), do: Chart.state(@chart, id1, id2) 311 | 312 | def new(), do: new(nil) 313 | def new(context), do: Execution.new(@chart, __MODULE__, context) 314 | def continue(state_name), do: continue(state_name, %{}) 315 | 316 | def continue(state_name, context), 317 | do: Execution.continue(@chart, __MODULE__, state_name, context) 318 | 319 | def put_context(execution, context), 320 | do: Execution.put_context(execution, context) 321 | 322 | def put_context(execution, key, value), 323 | do: Execution.put_context(execution, key, value) 324 | 325 | def with_completed(execution, state, step, decision \\ nil), 326 | do: Execution.with_completed(execution, state, step, decision) 327 | 328 | def will_transition?(execution, event), do: Execution.will_transition?(execution, event) 329 | def complete?(execution), do: Execution.complete?(execution) 330 | def transition(execution, event), do: Execution.transition(execution, event) 331 | def transition!(execution, event), do: Execution.transition!(execution, event) 332 | def transition_maybe(execution, event), do: Execution.transition_maybe(execution, event) 333 | def complete(execution, step), do: Execution.complete(execution, step) 334 | def decision(execution, step, decision), do: Execution.decision(execution, step, decision) 335 | def execute_actions(execution), do: Execution.execute_actions(execution) 336 | def execute_actions!(execution), do: Execution.execute_actions!(execution) 337 | def dump(execution), do: Execution.dump(execution) 338 | def updated({:ok, context}), do: {:updated, context} 339 | def updated(x), do: x 340 | def updated({:ok, value}, key), do: {:updated, {key, value}} 341 | def updated(x, _), do: x 342 | end 343 | end 344 | end 345 | -------------------------------------------------------------------------------- /test/ex_state/definition/compiler_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExState.Definition.CompilerTest do 2 | use ExUnit.Case, async: true 3 | 4 | defmodule Thing do 5 | defstruct [:id] 6 | end 7 | 8 | defmodule TestWorkflow do 9 | use ExState.Definition 10 | 11 | workflow "test" do 12 | subject :thing, Thing 13 | 14 | participant :seller 15 | participant :buyer 16 | 17 | initial_state :working 18 | 19 | state :working do 20 | on_entry :notify_working 21 | on_entry :log_stuff 22 | on_exit :notify_not_working 23 | 24 | initial_state :subscription 25 | 26 | on :reject, :rejected 27 | on :close, :closed 28 | 29 | state :subscription do 30 | initial_state :accepting 31 | 32 | state :accepting do 33 | step :accept, participant: :seller 34 | on_completed :accept, :confirming 35 | end 36 | 37 | state :confirming do 38 | repeatable :accept 39 | step :confirm, participant: :seller 40 | on_completed :confirming, :submitting 41 | end 42 | 43 | state :submitting do 44 | step :acknowledge, participant: :seller 45 | step :sign, participant: :seller 46 | on_completed :sign, {:<, :execution} 47 | end 48 | end 49 | 50 | state :execution do 51 | initial_state :accepting 52 | 53 | state :accepting do 54 | step :acknowledge, participant: :buyer 55 | step :countersign, participant: :buyer 56 | on_completed :countersign, :funding 57 | end 58 | end 59 | 60 | state :funding do 61 | initial_state :sending_funds 62 | 63 | state :sending_funds do 64 | step :send, participant: :seller 65 | 66 | parallel do 67 | step :verify 68 | step :evaluate, participant: :seller 69 | end 70 | 71 | on_decision :evaluate, :good, :closed 72 | on_decision :evaluate, :bad, :rejected 73 | end 74 | end 75 | end 76 | 77 | state :rejected do 78 | final 79 | end 80 | 81 | state :closed do 82 | final 83 | end 84 | end 85 | 86 | def log_stuff(_), do: :ok 87 | def notify_working(_), do: :ok 88 | def notify_not_working(_), do: :ok 89 | end 90 | 91 | test "compiles a workflow definition" do 92 | assert TestWorkflow.definition() == 93 | %ExState.Definition.Chart{ 94 | initial_state: "working", 95 | name: "test", 96 | subject: {:thing, ExState.Definition.CompilerTest.Thing}, 97 | participants: [:buyer, :seller], 98 | states: %{ 99 | "closed" => %ExState.Definition.State{ 100 | type: :final, 101 | initial_state: nil, 102 | name: "closed", 103 | steps: [], 104 | transitions: %{} 105 | }, 106 | "rejected" => %ExState.Definition.State{ 107 | type: :final, 108 | initial_state: nil, 109 | name: "rejected", 110 | steps: [], 111 | transitions: %{} 112 | }, 113 | "working" => %ExState.Definition.State{ 114 | type: :compound, 115 | actions: %{ 116 | entry: [:log_stuff, :notify_working], 117 | exit: [:notify_not_working] 118 | }, 119 | initial_state: "working.subscription", 120 | name: "working", 121 | steps: [], 122 | transitions: %{ 123 | close: %ExState.Definition.Transition{ 124 | event: :close, 125 | target: "closed" 126 | }, 127 | reject: %ExState.Definition.Transition{ 128 | event: :reject, 129 | target: "rejected" 130 | } 131 | } 132 | }, 133 | "working.execution" => %ExState.Definition.State{ 134 | type: :compound, 135 | initial_state: "working.execution.accepting", 136 | name: "working.execution", 137 | steps: [], 138 | transitions: %{} 139 | }, 140 | "working.execution.accepting" => %ExState.Definition.State{ 141 | type: :atomic, 142 | initial_state: nil, 143 | name: "working.execution.accepting", 144 | steps: [ 145 | %ExState.Definition.Step{ 146 | complete?: false, 147 | decision: nil, 148 | name: "countersign", 149 | order: 2, 150 | participant: :buyer 151 | }, 152 | %ExState.Definition.Step{ 153 | complete?: false, 154 | decision: nil, 155 | name: "acknowledge", 156 | order: 1, 157 | participant: :buyer 158 | } 159 | ], 160 | transitions: %{ 161 | {:completed, :countersign} => %ExState.Definition.Transition{ 162 | event: {:completed, :countersign}, 163 | target: "working.execution.funding" 164 | } 165 | } 166 | }, 167 | "working.funding" => %ExState.Definition.State{ 168 | type: :compound, 169 | initial_state: "working.funding.sending_funds", 170 | name: "working.funding", 171 | steps: [], 172 | transitions: %{} 173 | }, 174 | "working.funding.sending_funds" => %ExState.Definition.State{ 175 | type: :atomic, 176 | initial_state: nil, 177 | name: "working.funding.sending_funds", 178 | steps: [ 179 | %ExState.Definition.Step{ 180 | complete?: false, 181 | decision: nil, 182 | name: "evaluate", 183 | order: 2, 184 | participant: :seller 185 | }, 186 | %ExState.Definition.Step{ 187 | complete?: false, 188 | decision: nil, 189 | name: "verify", 190 | order: 2, 191 | participant: nil 192 | }, 193 | %ExState.Definition.Step{ 194 | complete?: false, 195 | decision: nil, 196 | name: "send", 197 | order: 1, 198 | participant: :seller 199 | } 200 | ], 201 | transitions: %{ 202 | {:decision, :evaluate, :bad} => %ExState.Definition.Transition{ 203 | event: {:decision, :evaluate, :bad}, 204 | target: "working.funding.rejected" 205 | }, 206 | {:decision, :evaluate, :good} => %ExState.Definition.Transition{ 207 | event: {:decision, :evaluate, :good}, 208 | target: "working.funding.closed" 209 | } 210 | } 211 | }, 212 | "working.subscription" => %ExState.Definition.State{ 213 | type: :compound, 214 | initial_state: "working.subscription.accepting", 215 | name: "working.subscription", 216 | steps: [], 217 | transitions: %{} 218 | }, 219 | "working.subscription.accepting" => %ExState.Definition.State{ 220 | type: :atomic, 221 | initial_state: nil, 222 | name: "working.subscription.accepting", 223 | steps: [ 224 | %ExState.Definition.Step{ 225 | complete?: false, 226 | decision: nil, 227 | name: "accept", 228 | order: 1, 229 | participant: :seller 230 | } 231 | ], 232 | transitions: %{ 233 | {:completed, :accept} => %ExState.Definition.Transition{ 234 | event: {:completed, :accept}, 235 | target: "working.subscription.confirming" 236 | } 237 | } 238 | }, 239 | "working.subscription.confirming" => %ExState.Definition.State{ 240 | type: :atomic, 241 | initial_state: nil, 242 | name: "working.subscription.confirming", 243 | steps: [ 244 | %ExState.Definition.Step{ 245 | complete?: false, 246 | decision: nil, 247 | name: "confirm", 248 | order: 1, 249 | participant: :seller 250 | } 251 | ], 252 | repeatable_steps: ["accept"], 253 | transitions: %{ 254 | {:completed, :confirming} => %ExState.Definition.Transition{ 255 | event: {:completed, :confirming}, 256 | target: "working.subscription.submitting" 257 | } 258 | } 259 | }, 260 | "working.subscription.submitting" => %ExState.Definition.State{ 261 | type: :atomic, 262 | initial_state: nil, 263 | name: "working.subscription.submitting", 264 | steps: [ 265 | %ExState.Definition.Step{ 266 | complete?: false, 267 | decision: nil, 268 | name: "sign", 269 | order: 2, 270 | participant: :seller 271 | }, 272 | %ExState.Definition.Step{ 273 | complete?: false, 274 | decision: nil, 275 | name: "acknowledge", 276 | order: 1, 277 | participant: :seller 278 | } 279 | ], 280 | transitions: %{ 281 | {:completed, :sign} => %ExState.Definition.Transition{ 282 | event: {:completed, :sign}, 283 | target: "working.execution" 284 | } 285 | } 286 | } 287 | } 288 | } 289 | end 290 | end 291 | -------------------------------------------------------------------------------- /lib/ex_state/execution.ex: -------------------------------------------------------------------------------- 1 | defmodule ExState.Execution do 2 | @moduledoc """ 3 | `ExState.Execution` executes state transitions with a state chart. 4 | """ 5 | 6 | alias ExState.Result 7 | alias ExState.Definition.Chart 8 | alias ExState.Definition.State 9 | alias ExState.Definition.Step 10 | alias ExState.Definition.Transition 11 | 12 | @type t :: %__MODULE__{ 13 | chart: Chart.t(), 14 | state: State.t(), 15 | actions: [atom()], 16 | history: [State.t()], 17 | transitions: [Transition.t()], 18 | callback_mod: module(), 19 | context: map(), 20 | meta: map() 21 | } 22 | 23 | defstruct chart: %Chart{}, 24 | state: nil, 25 | actions: [], 26 | history: [], 27 | transitions: [], 28 | callback_mod: nil, 29 | context: %{}, 30 | meta: %{} 31 | 32 | @doc """ 33 | Creates a new workflow execution from the initial state. 34 | """ 35 | @spec new(module()) :: t() 36 | def new(workflow) do 37 | new(workflow.definition, workflow, %{}) 38 | end 39 | 40 | @spec new(module(), map()) :: t() 41 | def new(workflow, context) do 42 | new(workflow.definition, workflow, context) 43 | end 44 | 45 | @spec new(Chart.t(), module(), map()) :: t() 46 | def new(chart, callback_mod, context) do 47 | %__MODULE__{chart: chart, callback_mod: callback_mod, context: context} 48 | |> enter_state(chart.initial_state) 49 | end 50 | 51 | @doc """ 52 | Continues a workflow execution from the specified state. 53 | """ 54 | @spec continue(module(), String.t()) :: t() 55 | def continue(workflow, state_name) do 56 | continue(workflow.definition, workflow, state_name, %{}) 57 | end 58 | 59 | @spec continue(module(), String.t(), map()) :: t() 60 | def continue(workflow, state_name, context) do 61 | continue(workflow.definition, workflow, state_name, context) 62 | end 63 | 64 | @spec continue(Chart.t(), module(), String.t(), map()) :: t() 65 | def continue(chart, callback_mod, state_name, context) when is_bitstring(state_name) do 66 | %__MODULE__{chart: chart, callback_mod: callback_mod, context: context} 67 | |> enter_state(state_name, entry_actions: false) 68 | end 69 | 70 | def put_subject(execution, subject) do 71 | case execution.chart.subject do 72 | {name, _queryable} -> 73 | put_context(execution, name, subject) 74 | 75 | nil -> 76 | raise "No subject defined in chart" 77 | end 78 | end 79 | 80 | def get_subject(execution) do 81 | case execution.chart.subject do 82 | {name, _queryable} -> 83 | Map.get(execution.context, name) 84 | 85 | nil -> 86 | nil 87 | end 88 | end 89 | 90 | def put_context(execution, context) do 91 | %{execution | context: context} 92 | end 93 | 94 | def put_context(execution, key, value) do 95 | %{execution | context: Map.put(execution.context, key, value)} 96 | end 97 | 98 | @doc """ 99 | Continues a workflow execution with the completed steps. 100 | Use in conjunction with `continue` to resume execution. 101 | """ 102 | @spec with_completed(t(), String.t(), String.t(), any()) :: t() 103 | def with_completed(execution, state_name, step_name, decision \\ nil) 104 | 105 | def with_completed( 106 | %__MODULE__{state: %State{name: state_name}} = execution, 107 | state_name, 108 | step_name, 109 | decision 110 | ) do 111 | put_state(execution, State.put_completed_step(execution.state, step_name, decision)) 112 | end 113 | 114 | def with_completed(execution, state_name, step_name, decision) do 115 | case Enum.find(execution.history, fn state -> state.name == state_name end) do 116 | nil -> 117 | put_history( 118 | execution, 119 | State.put_completed_step(get_state(execution, state_name), step_name, decision) 120 | ) 121 | 122 | state -> 123 | put_history( 124 | execution, 125 | Enum.map(execution.history, fn 126 | %State{name: ^state_name} -> State.put_completed_step(state, step_name, decision) 127 | state -> state 128 | end) 129 | ) 130 | end 131 | end 132 | 133 | @spec with_meta(t(), any(), any()) :: t() 134 | def with_meta(execution, key, value) do 135 | %__MODULE__{execution | meta: Map.put(execution.meta, key, value)} 136 | end 137 | 138 | defp enter_state(execution, name, opts \\ []) 139 | 140 | defp enter_state(execution, name, opts) when is_bitstring(name) do 141 | enter_state(execution, get_state(execution, name), opts) 142 | end 143 | 144 | defp enter_state(execution, %State{} = state, opts) do 145 | execution 146 | |> put_history() 147 | |> put_state(state) 148 | |> filter_steps() 149 | |> put_actions(opts) 150 | |> enter_initial_state() 151 | |> handle_final() 152 | |> handle_null() 153 | |> handle_no_steps() 154 | end 155 | 156 | defp enter_initial_state(%__MODULE__{state: %State{initial_state: nil}} = execution) do 157 | execution 158 | end 159 | 160 | defp enter_initial_state(%__MODULE__{state: %State{initial_state: initial_state}} = execution) do 161 | enter_state(execution, get_state(execution, initial_state), transition_actions: false) 162 | end 163 | 164 | defp handle_final(%__MODULE__{state: %State{type: :final}} = execution) do 165 | transition_maybe(execution, :__final__) 166 | end 167 | 168 | defp handle_final(execution) do 169 | execution 170 | end 171 | 172 | defp handle_null(execution) do 173 | transition_maybe(execution, :_) 174 | end 175 | 176 | defp handle_no_steps(%__MODULE__{state: %State{type: :atomic, steps: []}} = execution) do 177 | transition_maybe(execution, :__no_steps__) 178 | end 179 | 180 | defp handle_no_steps(execution) do 181 | execution 182 | end 183 | 184 | defp filter_steps(%__MODULE__{state: state} = execution) do 185 | put_state(execution, State.filter_steps(state, fn step -> use_step?(execution, step) end)) 186 | end 187 | 188 | defp put_history(%__MODULE__{state: nil} = execution), do: execution 189 | 190 | defp put_history(execution) do 191 | put_history(execution, execution.state) 192 | end 193 | 194 | defp put_history(execution, %State{} = state) do 195 | put_history(execution, [state | execution.history]) 196 | end 197 | 198 | defp put_history(execution, history) when is_list(history) do 199 | %__MODULE__{execution | history: history} 200 | end 201 | 202 | def get_state(execution, name) do 203 | Chart.state(execution.chart, name) 204 | end 205 | 206 | def put_state(execution, state) do 207 | %__MODULE__{execution | state: state} 208 | end 209 | 210 | def put_transition(execution, transition) do 211 | %__MODULE__{execution | transitions: [transition | execution.transitions]} 212 | end 213 | 214 | @doc """ 215 | Completes a step and transitions the execution with `{:completed, step_id}` event. 216 | """ 217 | @spec complete(t(), atom()) :: {:ok, t()} | {:error, String.t(), t()} 218 | def complete(execution, step_id) do 219 | case State.complete_step(execution.state, step_id) do 220 | {:ok, state} -> 221 | case do_transition(put_state(execution, state), {:completed, step_id}) do 222 | {:ok, execution} -> 223 | {:ok, execution} 224 | 225 | {:error, :no_transition, _reason, execution} -> 226 | {:ok, execution} 227 | 228 | {:error, _kind, reason, execution} -> 229 | {:error, reason, execution} 230 | end 231 | 232 | {:error, next_steps, state} -> 233 | {:error, step_error(next_steps), put_state(execution, state)} 234 | end 235 | end 236 | 237 | def complete!(execution, step_id) do 238 | complete(execution, step_id) |> Result.get() 239 | end 240 | 241 | @doc """ 242 | Completes a decision and transitions the execution with `{:decision, step_id, decision}` event. 243 | """ 244 | @spec decision(t(), atom(), atom()) :: {:ok, t()} | {:error, String.t(), t()} 245 | def decision(execution, step_id, decision) do 246 | case State.complete_step(execution.state, step_id, decision) do 247 | {:ok, state} -> 248 | case do_transition(put_state(execution, state), {:decision, step_id, decision}) do 249 | {:ok, execution} -> 250 | {:ok, execution} 251 | 252 | {:error, _kind, reason, execution} -> 253 | {:error, reason, execution} 254 | end 255 | 256 | {:error, next_steps, state} -> 257 | {:error, step_error(next_steps), put_state(execution, state)} 258 | end 259 | end 260 | 261 | def decision!(execution, step_id, decision) do 262 | decision(execution, step_id, decision) |> Result.get() 263 | end 264 | 265 | defp step_error([]), do: "no next step" 266 | defp step_error([next_step]), do: "next step is: #{next_step.name}" 267 | 268 | defp step_error(next_steps) when is_list(next_steps) do 269 | "next steps are: #{Enum.map(next_steps, fn step -> step.name end) |> Enum.join(", ")}" 270 | end 271 | 272 | @doc """ 273 | Transitions execution with the event and returns a result tuple. 274 | """ 275 | @spec transition(t(), Transition.event()) :: {:ok, t()} | {:error, String.t(), t()} 276 | def transition(execution, event) do 277 | case do_transition(execution, event) do 278 | {:ok, execution} -> 279 | {:ok, execution} 280 | 281 | {:error, _kind, reason, execution} -> 282 | {:error, reason, execution} 283 | end 284 | end 285 | 286 | def transition!(execution, event) do 287 | transition(execution, event) |> Result.get() 288 | end 289 | 290 | @doc """ 291 | Transitions execution with the event and returns updated or unchanged execution. 292 | """ 293 | def transition_maybe(execution, event) do 294 | case do_transition(execution, event) do 295 | {:ok, execution} -> 296 | execution 297 | 298 | {:error, _kind, _reason, execution} -> 299 | execution 300 | end 301 | end 302 | 303 | @spec do_transition(t(), Transition.event()) :: {:ok, t()} | {:error, atom(), any(), t()} 304 | defp do_transition(%__MODULE__{state: %State{name: current_state}} = execution, event) do 305 | case State.transition(execution.state, event) do 306 | nil -> 307 | case Chart.parent(execution.chart, execution.state) do 308 | nil -> 309 | no_transition(execution, event) 310 | 311 | parent -> 312 | case do_transition(put_state(execution, parent), event) do 313 | {:ok, execution} -> 314 | {:ok, execution} 315 | 316 | {:error, kind, reason, _} -> 317 | {:error, kind, reason, execution} 318 | end 319 | end 320 | 321 | %Transition{target: ^current_state, reset: false} = transition -> 322 | next = 323 | execution 324 | |> add_actions(transition.actions) 325 | 326 | {:ok, next} 327 | 328 | %Transition{target: target} = transition when is_list(target) -> 329 | Enum.reduce_while(target, no_transition(execution, event), fn target, e -> 330 | case use_target(execution, transition, target) do 331 | {:ok, next} -> {:halt, {:ok, next}} 332 | {:error, _code, _reason, _execution} -> {:cont, e} 333 | end 334 | end) 335 | 336 | %Transition{target: target} = transition -> 337 | use_target(execution, transition, target) 338 | end 339 | end 340 | 341 | defp no_transition(execution, event) do 342 | {:error, :no_transition, 343 | "no transition from #{execution.state.name} for event #{inspect(event)}", execution} 344 | end 345 | 346 | defp use_target(execution, transition, target) do 347 | case get_state(execution, target) do 348 | nil -> 349 | {:error, :no_state, "no state found for transition to #{target}", execution} 350 | 351 | state -> 352 | case guard_transition(execution, state) do 353 | :ok -> 354 | next = 355 | execution 356 | |> put_transition(transition) 357 | |> enter_state(state) 358 | 359 | {:ok, next} 360 | 361 | {:error, reason} -> 362 | {:error, :guard_transition, reason, execution} 363 | end 364 | end 365 | end 366 | 367 | defp guard_transition(execution, state) do 368 | if function_exported?(execution.callback_mod, :guard_transition, 3) do 369 | execution.callback_mod.guard_transition( 370 | State.id(execution.state), 371 | State.id(state), 372 | execution.context 373 | ) 374 | else 375 | :ok 376 | end 377 | end 378 | 379 | def will_transition?(execution, event) do 380 | transition_maybe(execution, event).state != execution.state 381 | end 382 | 383 | def complete?(execution), do: State.final?(execution.state) 384 | 385 | @doc """ 386 | Returns serializable data representing the execution. 387 | """ 388 | def dump(execution) do 389 | %{ 390 | name: execution.chart.name, 391 | state: execution.state.name, 392 | complete?: complete?(execution), 393 | steps: dump_steps(execution), 394 | participants: dump_participants(execution), 395 | context: execution.context 396 | } 397 | end 398 | 399 | defp dump_participants(execution) do 400 | Enum.map(execution.chart.participants, fn name -> 401 | dump_participant(name) 402 | end) 403 | end 404 | 405 | defp dump_participant(nil), do: nil 406 | defp dump_participant(name), do: Atom.to_string(name) 407 | 408 | defp dump_steps(execution) do 409 | execution 410 | |> merge_states() 411 | |> Enum.flat_map(fn state -> 412 | state.steps 413 | |> Enum.filter(fn step -> use_step?(execution, step) end) 414 | |> Enum.map(fn step -> 415 | %{ 416 | state: state.name, 417 | order: step.order, 418 | name: step.name, 419 | complete?: step.complete?, 420 | decision: step.decision, 421 | participant: dump_participant(step.participant) 422 | } 423 | end) 424 | end) 425 | end 426 | 427 | defp merge_states(execution) do 428 | Enum.map(execution.chart.states, fn {_, state} -> 429 | case execution.state.name == state.name do 430 | true -> 431 | execution.state 432 | 433 | false -> 434 | case Enum.find(execution.history, fn s -> s.name == state.name end) do 435 | nil -> state 436 | history_state -> history_state 437 | end 438 | end 439 | end) 440 | end 441 | 442 | defp use_step?(execution, step) do 443 | if function_exported?(execution.callback_mod, :use_step?, 2) do 444 | execution.callback_mod.use_step?(Step.id(step), execution.context) 445 | else 446 | true 447 | end 448 | end 449 | 450 | defp put_actions(execution, opts) do 451 | execution = 452 | if Keyword.get(opts, :exit_actions, true) do 453 | put_exit_actions(execution) 454 | else 455 | execution 456 | end 457 | 458 | execution = 459 | if Keyword.get(opts, :transition_actions, true) do 460 | put_transition_actions(execution) 461 | else 462 | execution 463 | end 464 | 465 | execution = 466 | if Keyword.get(opts, :entry_actions, true) do 467 | put_entry_actions(execution) 468 | else 469 | execution 470 | end 471 | 472 | execution 473 | end 474 | 475 | @doc """ 476 | Executes any queued actions on the execution. 477 | """ 478 | @spec execute_actions(t()) :: {:ok, t(), map()} | {:error, t(), any()} 479 | def execute_actions(execution) do 480 | execution.actions 481 | |> Enum.reverse() 482 | |> Enum.reduce({:ok, execution, %{}}, fn 483 | _next, {:error, _reason} = e -> 484 | e 485 | 486 | next, {:ok, execution, acc} -> 487 | case execute_action(execution, next) do 488 | {:ok, execution, result} -> 489 | {:ok, execution, Map.put(acc, next, result)} 490 | 491 | {:error, _reason} = e -> 492 | e 493 | end 494 | end) 495 | |> case do 496 | {:ok, execution, results} -> 497 | {:ok, reset_actions(execution), results} 498 | 499 | {:error, reason} -> 500 | {:error, execution, reason} 501 | end 502 | end 503 | 504 | @doc """ 505 | Executes the provided action name through the callback module. 506 | """ 507 | @spec execute_action(t(), atom()) :: {:ok, t(), any()} | {:error, any()} 508 | def execute_action(execution, action) do 509 | if function_exported?(execution.callback_mod, action, 1) do 510 | case apply(execution.callback_mod, action, [execution.context]) do 511 | :ok -> 512 | {:ok, execution, nil} 513 | 514 | {:ok, result} -> 515 | {:ok, execution, result} 516 | 517 | {:updated, {key, value}} -> 518 | context = Map.put(execution.context, key, value) 519 | {:ok, %__MODULE__{execution | context: context}, context} 520 | 521 | {:updated, context} -> 522 | {:ok, %__MODULE__{execution | context: context}, context} 523 | 524 | e -> 525 | e 526 | end 527 | else 528 | {:error, "no function defined for action #{action}"} 529 | end 530 | end 531 | 532 | def execute_actions!(execution) do 533 | {:ok, execution, _} = execute_actions(execution) 534 | execution 535 | end 536 | 537 | defp reset_actions(execution) do 538 | %__MODULE__{execution | actions: []} 539 | end 540 | 541 | defp add_actions(execution, actions) do 542 | %__MODULE__{execution | actions: actions ++ execution.actions} 543 | end 544 | 545 | defp put_exit_actions(%__MODULE__{state: current, history: [last | _rest]} = execution) do 546 | cond do 547 | State.child?(last, current) -> 548 | execution 549 | 550 | !State.sibling?(last, current) -> 551 | execution 552 | |> add_actions(State.actions(last, :exit)) 553 | |> add_actions(State.actions(Chart.parent(execution.chart, last), :exit)) 554 | 555 | true -> 556 | add_actions(execution, State.actions(last, :exit)) 557 | end 558 | end 559 | 560 | defp put_exit_actions(execution), do: execution 561 | 562 | defp put_transition_actions(%__MODULE__{transitions: [last | _rest]} = execution) do 563 | add_actions(execution, last.actions) 564 | end 565 | 566 | defp put_transition_actions(execution), do: execution 567 | 568 | defp put_entry_actions(%__MODULE__{state: current} = execution) do 569 | add_actions(execution, State.actions(current, :entry)) 570 | end 571 | end 572 | -------------------------------------------------------------------------------- /test/ex_state/definition_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExState.DefinitionTest do 2 | use ExUnit.Case, async: true 3 | 4 | defmodule SimpleWorkflow do 5 | use ExState.Definition 6 | 7 | workflow "simple" do 8 | initial_state :a 9 | 10 | state :a do 11 | on :go_to_b, :b 12 | end 13 | 14 | state :b 15 | state :c 16 | end 17 | end 18 | 19 | defmodule Message do 20 | defstruct review?: true, confirm?: true, sender_id: nil, recipient_id: nil, feedback: nil 21 | end 22 | 23 | defmodule SendWorkflow do 24 | use ExState.Definition 25 | 26 | workflow "send" do 27 | subject :message, Message 28 | 29 | participant :sender 30 | participant :recipient 31 | 32 | initial_state :pending 33 | 34 | state :pending do 35 | on_entry :notify_started 36 | on_exit :log_stuff 37 | 38 | initial_state :sending 39 | 40 | on :cancel, :cancelled 41 | on :ignore, :ignored 42 | 43 | state :sending do 44 | step :prepare, participant: :sender, repeatable: true 45 | step :review, participant: :sender, repeatable: true 46 | step :send, participant: :sender 47 | on_completed :send, :sent 48 | end 49 | 50 | state :sent do 51 | step :confirm, participant: :recipient 52 | on_completed :confirm, {:<, :confirmed} 53 | end 54 | end 55 | 56 | state :confirmed do 57 | initial_state :deciding 58 | 59 | state :deciding do 60 | step :decide 61 | on_decision :decide, :good, {:<, :good} 62 | on_decision :decide, :bad, {:<, :bad} 63 | end 64 | end 65 | 66 | state :cancelled do 67 | on_entry :notify_cancelled 68 | on :cancel, :_ 69 | end 70 | 71 | state :ignored do 72 | step :remind, repeatable: true 73 | end 74 | 75 | state :good do 76 | final 77 | end 78 | 79 | state :bad do 80 | final 81 | end 82 | end 83 | 84 | def participant(:sender, %{message: m}), do: m.sender_id 85 | def participant(:recipient, %{message: m}), do: m.recipient_id 86 | 87 | def use_step?(:review, %{message: m}), do: m.review? 88 | def use_step?(:confirm, %{message: m}), do: m.confirm? 89 | def use_step?(_, _), do: true 90 | 91 | def notify_started(_), do: {:ok, "notified started"} 92 | def notify_cancelled(_), do: {:ok, "notified cancelled"} 93 | def log_stuff(_), do: :ok 94 | end 95 | 96 | describe "name/0" do 97 | test "defines name" do 98 | assert SimpleWorkflow.name() == "simple" 99 | end 100 | end 101 | 102 | describe "subject/0" do 103 | test "defines subject type" do 104 | assert SendWorkflow.subject() == {:message, Message} 105 | end 106 | end 107 | 108 | describe "initial_state" do 109 | test "defines initial state" do 110 | assert SimpleWorkflow.initial_state() == "a" 111 | end 112 | 113 | test "transitions to initial states" do 114 | assert SendWorkflow.new(%{message: %Message{}}) 115 | |> Map.get(:state) 116 | |> Map.get(:name) == "pending.sending" 117 | end 118 | end 119 | 120 | describe "state" do 121 | test "defines simple state" do 122 | assert SimpleWorkflow.state(:b).name == "b" 123 | end 124 | 125 | test "defines sub states" do 126 | assert SendWorkflow.state(:pending).initial_state == "pending.sending" 127 | assert SendWorkflow.state(:pending, :sending).name == "pending.sending" 128 | assert SendWorkflow.state(:confirmed, :deciding).name == "confirmed.deciding" 129 | end 130 | 131 | test "defines state with steps" do 132 | assert SendWorkflow.state(:pending, :sending).steps 133 | |> Enum.sort_by(& &1.order) 134 | |> Enum.map(& &1.name) == [ 135 | "prepare", 136 | "review", 137 | "send" 138 | ] 139 | end 140 | end 141 | 142 | describe "transition/2" do 143 | test "defines event transition" do 144 | assert SimpleWorkflow.continue("a") 145 | |> SimpleWorkflow.transition_maybe(:go_to_b) 146 | |> Map.get(:state) 147 | |> Map.get(:name) == "b" 148 | 149 | assert SimpleWorkflow.continue("b") 150 | |> SimpleWorkflow.transition_maybe(:go_to_b) 151 | |> Map.get(:state) 152 | |> Map.get(:name) == "b" 153 | 154 | assert SimpleWorkflow.continue("c") 155 | |> SimpleWorkflow.transition_maybe(:go_to_b) 156 | |> Map.get(:state) 157 | |> Map.get(:name) == "c" 158 | end 159 | 160 | test "defines completed step transition" do 161 | assert SendWorkflow.continue("pending.sending", %{message: %Message{}}) 162 | |> SendWorkflow.transition_maybe({:completed, :send}) 163 | |> Map.get(:state) 164 | |> Map.get(:name) == "pending.sent" 165 | end 166 | 167 | test "defines decision transition" do 168 | assert SendWorkflow.continue("confirmed.deciding", %{message: %Message{}}) 169 | |> SendWorkflow.transition_maybe({:decision, :decide, :good}) 170 | |> Map.get(:state) 171 | |> Map.get(:name) == "good" 172 | 173 | assert SendWorkflow.continue("confirmed.deciding", %{message: %Message{}}) 174 | |> SendWorkflow.transition_maybe({:decision, :decide, :bad}) 175 | |> Map.get(:state) 176 | |> Map.get(:name) == "bad" 177 | end 178 | 179 | test "transitions to initial state" do 180 | assert SendWorkflow.new(%{message: %Message{}}) 181 | |> Map.get(:state) 182 | |> Map.get(:name) == "pending.sending" 183 | 184 | assert SendWorkflow.new(%{message: %Message{}}) 185 | |> SendWorkflow.transition_maybe({:completed, :send}) 186 | |> SendWorkflow.transition_maybe({:completed, :confirm}) 187 | |> Map.get(:state) 188 | |> Map.get(:name) == "confirmed.deciding" 189 | end 190 | 191 | test "defines parent state transition" do 192 | assert SendWorkflow.continue("pending.sent", %{message: %Message{}}) 193 | |> SendWorkflow.transition_maybe({:completed, :confirm}) 194 | |> Map.get(:state) 195 | |> Map.get(:name) == "confirmed.deciding" 196 | end 197 | 198 | test "completes a workflow" do 199 | execution = 200 | SendWorkflow.new(%{message: %Message{}}) 201 | |> SendWorkflow.transition_maybe({:completed, :send}) 202 | |> SendWorkflow.transition_maybe({:completed, :confirm}) 203 | |> SendWorkflow.transition_maybe({:decision, :decide, :good}) 204 | 205 | assert execution.state.name == "good" 206 | assert SendWorkflow.complete?(execution) 207 | end 208 | 209 | test "passes event to parent states" do 210 | execution = 211 | SendWorkflow.new(%{message: %Message{}}) 212 | |> SendWorkflow.transition_maybe(:cancel) 213 | 214 | assert execution.state.name == "cancelled" 215 | end 216 | 217 | test "ignores invalid transitions" do 218 | execution = 219 | SendWorkflow.new(%{message: %Message{}}) 220 | |> SendWorkflow.transition_maybe({:completed, :prepare}) 221 | |> SendWorkflow.transition_maybe({:completed, :review}) 222 | |> SendWorkflow.transition_maybe({:completed, :send}) 223 | |> SendWorkflow.transition_maybe({:completed, :confirm}) 224 | |> SendWorkflow.transition_maybe(:cancel) 225 | |> SendWorkflow.transition_maybe({:decision, :decide, :good}) 226 | 227 | assert execution.state.name == "good" 228 | end 229 | 230 | test "allows defined internal transitions" do 231 | assert {:ok, _} = 232 | SendWorkflow.new(%{message: %Message{}}) 233 | |> SendWorkflow.transition!(:cancel) 234 | |> SendWorkflow.transition(:cancel) 235 | end 236 | 237 | test "returns triggered actions" do 238 | assert [ 239 | :notify_cancelled, 240 | :log_stuff, 241 | :notify_started 242 | ] = 243 | SendWorkflow.new(%Message{}) 244 | |> SendWorkflow.transition_maybe(:cancel) 245 | |> Map.get(:actions) 246 | 247 | assert [ 248 | :log_stuff, 249 | :notify_started 250 | ] = 251 | SendWorkflow.new(%Message{}) 252 | |> SendWorkflow.transition_maybe({:completed, :send}) 253 | |> SendWorkflow.transition_maybe({:completed, :confirm}) 254 | |> Map.get(:actions) 255 | end 256 | 257 | test "continue does not trigger entry actions" do 258 | assert [ 259 | :notify_cancelled, 260 | :log_stuff 261 | ] = 262 | SendWorkflow.continue("pending", %{message: %Message{}}) 263 | |> SendWorkflow.transition_maybe(:cancel) 264 | |> Map.get(:actions) 265 | 266 | assert [ 267 | :log_stuff, 268 | :notify_started 269 | ] = 270 | SendWorkflow.new(%Message{}) 271 | |> SendWorkflow.transition_maybe({:completed, :send}) 272 | |> SendWorkflow.transition_maybe({:completed, :confirm}) 273 | |> Map.get(:actions) 274 | end 275 | 276 | test "executes triggered actions" do 277 | {:ok, execution, results} = 278 | SendWorkflow.new(%Message{}) 279 | |> SendWorkflow.transition_maybe(:cancel) 280 | |> SendWorkflow.execute_actions() 281 | 282 | assert execution.state.name == "cancelled" 283 | 284 | assert results == %{ 285 | log_stuff: nil, 286 | notify_started: "notified started", 287 | notify_cancelled: "notified cancelled" 288 | } 289 | end 290 | end 291 | 292 | describe "will_transition/2" do 293 | test "determines valid transitions" do 294 | execution = SendWorkflow.new(%Message{}) 295 | 296 | assert SendWorkflow.will_transition?(execution, {:completed, :send}) 297 | refute SendWorkflow.will_transition?(execution, {:completed, :confirm}) 298 | end 299 | end 300 | 301 | describe "complete/2" do 302 | test "complete step returns ok" do 303 | assert {:ok, %{state: %{name: "pending.sending"}} = execution} = 304 | SendWorkflow.new(%Message{}) 305 | |> SendWorkflow.complete(:prepare) 306 | 307 | assert {:ok, %{state: %{name: "pending.sending"}} = execution} = 308 | execution 309 | |> SendWorkflow.complete(:review) 310 | 311 | assert {:ok, %{state: %{name: "pending.sent"}} = execution} = 312 | execution 313 | |> SendWorkflow.complete(:send) 314 | 315 | assert {:error, _, %{state: %{name: "pending.sent"}}} = 316 | execution 317 | |> SendWorkflow.complete(:send) 318 | end 319 | 320 | test "complete step returns ok for repeatable step" do 321 | assert {:ok, %{state: %{name: "pending.sending"}} = execution} = 322 | SendWorkflow.new(%Message{}) 323 | |> SendWorkflow.complete(:prepare) 324 | 325 | assert {:ok, %{state: %{name: "pending.sending"}}} = 326 | execution 327 | |> SendWorkflow.complete(:prepare) 328 | end 329 | 330 | test "completes step returns ok for repeatable last step" do 331 | assert {:ok, %{state: %{name: "ignored"}} = execution} = 332 | SendWorkflow.new(%Message{}) 333 | |> SendWorkflow.transition_maybe(:ignore) 334 | |> SendWorkflow.complete(:remind) 335 | 336 | assert {:ok, %{state: %{name: "ignored"}}} = 337 | execution 338 | |> SendWorkflow.complete(:remind) 339 | end 340 | 341 | test "complete step returns error for repeatable step out of order" do 342 | assert {:error, _, %{state: %{name: "pending.sending"}}} = 343 | SendWorkflow.new(%Message{}) 344 | |> SendWorkflow.complete(:review) 345 | end 346 | 347 | test "complete step accounts for unused steps" do 348 | assert {:ok, %{state: %{name: "pending.sending"}} = execution} = 349 | SendWorkflow.new(%{message: %Message{review?: false}}) 350 | |> SendWorkflow.complete(:prepare) 351 | 352 | assert {:error, _, %{state: %{name: "pending.sending"}} = execution} = 353 | execution 354 | |> SendWorkflow.complete(:review) 355 | 356 | assert {:ok, %{state: %{name: "pending.sent"}} = execution} = 357 | execution 358 | |> SendWorkflow.complete(:send) 359 | end 360 | 361 | test "complete step returns error" do 362 | assert {:error, _, %{state: %{name: "pending.sending"}}} = 363 | SendWorkflow.new(%{message: %Message{}}) 364 | |> SendWorkflow.complete(:send) 365 | end 366 | end 367 | 368 | defmodule ParallelWorkflow do 369 | use ExState.Definition 370 | 371 | workflow "rate" do 372 | subject :message, Message 373 | 374 | initial_state :not_done 375 | 376 | state :not_done do 377 | parallel do 378 | step :do_one_thing 379 | step :do_another_thing 380 | end 381 | 382 | step :do_last_thing 383 | on_completed :do_last_thing, :done 384 | end 385 | 386 | state :done 387 | end 388 | end 389 | 390 | describe "parallel steps" do 391 | test "completes steps in any order" do 392 | assert {:ok, %{state: %{name: "not_done"}} = execution} = 393 | ParallelWorkflow.new(%Message{}) 394 | |> ParallelWorkflow.complete(:do_another_thing) 395 | 396 | assert {:error, reason, %{state: %{name: "not_done"}} = execution} = 397 | execution 398 | |> ParallelWorkflow.complete(:do_last_thing) 399 | 400 | assert reason == "next step is: do_one_thing" 401 | 402 | assert {:ok, %{state: %{name: "not_done"}} = execution} = 403 | execution 404 | |> ParallelWorkflow.complete(:do_one_thing) 405 | 406 | assert {:ok, %{state: %{name: "done"}} = execution} = 407 | execution 408 | |> ParallelWorkflow.complete(:do_last_thing) 409 | end 410 | end 411 | 412 | describe "with_completed/2" do 413 | test "loads state with completed steps" do 414 | assert {:ok, %{state: %{name: "pending.sent"}}} = 415 | SendWorkflow.continue("pending.sending", %{message: %Message{}}) 416 | |> SendWorkflow.with_completed("pending.sending", "prepare") 417 | |> SendWorkflow.with_completed("pending.sending", "review") 418 | |> SendWorkflow.complete(:send) 419 | end 420 | end 421 | 422 | defmodule DecisionWorkflow do 423 | use ExState.Definition 424 | 425 | workflow "rate" do 426 | subject :message, Message 427 | 428 | initial_state :not_rated 429 | 430 | state :not_rated do 431 | step :rate 432 | on_decision :rate, :good, :done 433 | on_decision :rate, :bad, :feedback 434 | end 435 | 436 | state :feedback do 437 | repeatable :rate 438 | on_decision :rate, :good, :done 439 | on_decision :rate, :bad, :_, reset: false 440 | 441 | step :confirm_rating 442 | step :provide_feedback 443 | on_completed :provide_feedback, :done 444 | end 445 | 446 | state :done 447 | end 448 | 449 | def guard_transition(_, :done, %{message: message}) do 450 | if message.feedback == "too short" do 451 | {:error, "feedback is too short to be done"} 452 | else 453 | :ok 454 | end 455 | end 456 | 457 | def guard_transition(_, _, _), do: :ok 458 | end 459 | 460 | describe "decision/3" do 461 | test "transitions to new state" do 462 | assert {:ok, %{state: %{name: "done"}}} = 463 | DecisionWorkflow.new(%{message: %Message{}}) 464 | |> DecisionWorkflow.decision(:rate, :good) 465 | end 466 | 467 | test "returns error for unknown decision" do 468 | assert {:error, _, _} = 469 | DecisionWorkflow.new(%{message: %Message{}}) 470 | |> DecisionWorkflow.decision(:rate, :something_else) 471 | end 472 | 473 | test "returns error for guarded transition" do 474 | assert {:error, reason, _} = 475 | DecisionWorkflow.continue("feedback", %{message: %Message{feedback: "too short"}}) 476 | |> DecisionWorkflow.with_completed("feedback", "confirm_rating") 477 | |> DecisionWorkflow.complete(:provide_feedback) 478 | 479 | assert reason == "feedback is too short to be done" 480 | end 481 | 482 | test "handles repeatable decision" do 483 | assert {:ok, %{state: %{name: "feedback"}} = execution} = 484 | DecisionWorkflow.new(%{message: %Message{}}) 485 | |> DecisionWorkflow.decision(:rate, :bad) 486 | 487 | assert {:ok, %{state: %{name: "feedback"}} = execution} = 488 | execution 489 | |> DecisionWorkflow.decision(:rate, :bad) 490 | 491 | assert {:ok, %{state: %{name: "feedback"}} = execution} = 492 | execution 493 | |> DecisionWorkflow.complete(:confirm_rating) 494 | 495 | assert {:ok, %{state: %{name: "feedback"}} = execution} = 496 | execution 497 | |> DecisionWorkflow.decision(:rate, :bad) 498 | 499 | assert Enum.find(execution.state.steps, fn s -> s.name == "confirm_rating" end).complete? 500 | 501 | assert {:ok, %{state: %{name: "done"}} = execution} = 502 | execution 503 | |> DecisionWorkflow.decision(:rate, :good) 504 | end 505 | end 506 | 507 | describe "dump/1" do 508 | test "returns workflow data" do 509 | message = %Message{confirm?: true, sender_id: 1, recipient_id: 2} 510 | 511 | assert SendWorkflow.new(%{message: message}) |> SendWorkflow.dump() == %{ 512 | name: "send", 513 | complete?: false, 514 | state: "pending.sending", 515 | context: %{message: message}, 516 | participants: [ 517 | "recipient", 518 | "sender" 519 | ], 520 | steps: [ 521 | %{ 522 | name: "decide", 523 | order: 1, 524 | participant: nil, 525 | state: "confirmed.deciding", 526 | complete?: false, 527 | decision: nil 528 | }, 529 | %{ 530 | name: "remind", 531 | order: 1, 532 | participant: nil, 533 | state: "ignored", 534 | complete?: false, 535 | decision: nil 536 | }, 537 | %{ 538 | name: "send", 539 | order: 3, 540 | participant: "sender", 541 | state: "pending.sending", 542 | complete?: false, 543 | decision: nil 544 | }, 545 | %{ 546 | name: "review", 547 | order: 2, 548 | participant: "sender", 549 | state: "pending.sending", 550 | complete?: false, 551 | decision: nil 552 | }, 553 | %{ 554 | name: "prepare", 555 | order: 1, 556 | participant: "sender", 557 | state: "pending.sending", 558 | complete?: false, 559 | decision: nil 560 | }, 561 | %{ 562 | name: "confirm", 563 | order: 1, 564 | participant: "recipient", 565 | state: "pending.sent", 566 | complete?: false, 567 | decision: nil 568 | } 569 | ] 570 | } 571 | end 572 | 573 | test "excludes unused steps" do 574 | confirmed_message = %Message{confirm?: false, sender_id: 1, recipient_id: 2} 575 | 576 | assert SendWorkflow.new(%{message: confirmed_message}) |> SendWorkflow.dump() == %{ 577 | name: "send", 578 | complete?: false, 579 | state: "pending.sending", 580 | context: %{message: confirmed_message}, 581 | participants: [ 582 | "recipient", 583 | "sender" 584 | ], 585 | steps: [ 586 | %{ 587 | name: "decide", 588 | order: 1, 589 | participant: nil, 590 | state: "confirmed.deciding", 591 | complete?: false, 592 | decision: nil 593 | }, 594 | %{ 595 | name: "remind", 596 | order: 1, 597 | participant: nil, 598 | state: "ignored", 599 | complete?: false, 600 | decision: nil 601 | }, 602 | %{ 603 | name: "send", 604 | order: 3, 605 | participant: "sender", 606 | state: "pending.sending", 607 | complete?: false, 608 | decision: nil 609 | }, 610 | %{ 611 | name: "review", 612 | order: 2, 613 | participant: "sender", 614 | state: "pending.sending", 615 | complete?: false, 616 | decision: nil 617 | }, 618 | %{ 619 | name: "prepare", 620 | order: 1, 621 | participant: "sender", 622 | state: "pending.sending", 623 | complete?: false, 624 | decision: nil 625 | } 626 | ] 627 | } 628 | end 629 | 630 | test "includes completed steps from previous states" do 631 | message = %Message{confirm?: true, sender_id: 1, recipient_id: 2} 632 | 633 | assert SendWorkflow.continue("confirmed.deciding", %{message: message}) 634 | |> SendWorkflow.with_completed("pending.sending", "prepare") 635 | |> SendWorkflow.with_completed("pending.sending", "review") 636 | |> SendWorkflow.with_completed("pending.sending", "send") 637 | |> SendWorkflow.dump() == %{ 638 | name: "send", 639 | complete?: false, 640 | state: "confirmed.deciding", 641 | context: %{message: message}, 642 | participants: [ 643 | "recipient", 644 | "sender" 645 | ], 646 | steps: [ 647 | %{ 648 | name: "decide", 649 | order: 1, 650 | participant: nil, 651 | state: "confirmed.deciding", 652 | complete?: false, 653 | decision: nil 654 | }, 655 | %{ 656 | name: "remind", 657 | order: 1, 658 | participant: nil, 659 | state: "ignored", 660 | complete?: false, 661 | decision: nil 662 | }, 663 | %{ 664 | name: "send", 665 | order: 3, 666 | participant: "sender", 667 | state: "pending.sending", 668 | complete?: true, 669 | decision: nil 670 | }, 671 | %{ 672 | name: "review", 673 | order: 2, 674 | participant: "sender", 675 | state: "pending.sending", 676 | complete?: true, 677 | decision: nil 678 | }, 679 | %{ 680 | name: "prepare", 681 | order: 1, 682 | participant: "sender", 683 | state: "pending.sending", 684 | complete?: true, 685 | decision: nil 686 | }, 687 | %{ 688 | name: "confirm", 689 | order: 1, 690 | participant: "recipient", 691 | state: "pending.sent", 692 | complete?: false, 693 | decision: nil 694 | } 695 | ] 696 | } 697 | end 698 | end 699 | 700 | defmodule OptionalWorkflow do 701 | use ExState.Definition 702 | 703 | workflow "optional_steps" do 704 | subject :message, Message 705 | 706 | initial_state :working 707 | 708 | state :working do 709 | step :a 710 | step :b 711 | 712 | on_completed :b, :complete 713 | on_no_steps :complete 714 | end 715 | 716 | state :complete 717 | end 718 | 719 | def use_step?(_, %{message: %Message{review?: review}}), do: review 720 | end 721 | 722 | test "handles no used steps" do 723 | message = %Message{review?: true} 724 | assert %{state: %{name: "working"}} = OptionalWorkflow.new(%{message: message}) 725 | 726 | message = %Message{review?: false} 727 | assert %{state: %{name: "complete"}} = OptionalWorkflow.new(%{message: message}) 728 | end 729 | 730 | defmodule VirtualWorkflow do 731 | use ExState.Definition 732 | 733 | workflow "virtual_states" do 734 | initial_state :completing_a 735 | 736 | virtual :working_states do 737 | initial_state :working 738 | 739 | state :working do 740 | step :read 741 | step :sign 742 | step :confirm 743 | end 744 | end 745 | 746 | state :completing_a do 747 | using :working_states 748 | on_completed :confirm, :completing_b 749 | end 750 | 751 | state :completing_b do 752 | using :working_states 753 | on_completed :confirm, :done 754 | end 755 | 756 | state :done 757 | end 758 | end 759 | 760 | test "uses virtual states" do 761 | assert {:ok, %{state: %{name: "completing_a.working"}} = execution} = 762 | VirtualWorkflow.new() 763 | |> VirtualWorkflow.complete(:read) 764 | 765 | assert {:ok, %{state: %{name: "completing_a.working"}} = execution} = 766 | execution 767 | |> VirtualWorkflow.complete(:sign) 768 | 769 | assert {:ok, %{state: %{name: "completing_b.working"}} = execution} = 770 | execution 771 | |> VirtualWorkflow.complete(:confirm) 772 | 773 | assert {:ok, %{state: %{name: "completing_b.working"}} = execution} = 774 | execution 775 | |> VirtualWorkflow.complete(:read) 776 | 777 | assert {:ok, %{state: %{name: "completing_b.working"}} = execution} = 778 | execution 779 | |> VirtualWorkflow.complete(:sign) 780 | 781 | assert {:ok, %{state: %{name: "done"}} = execution} = 782 | execution 783 | |> VirtualWorkflow.complete(:confirm) 784 | end 785 | 786 | defmodule FinalStateWorkflow do 787 | use ExState.Definition 788 | 789 | workflow "final_states" do 790 | initial_state :working 791 | 792 | state :working do 793 | initial_state :planning 794 | 795 | state :planning do 796 | on :_, [:building, :waiting] 797 | end 798 | 799 | state :waiting do 800 | on :start, :building 801 | end 802 | 803 | state :building do 804 | on :built, :done 805 | end 806 | 807 | state :done do 808 | final 809 | end 810 | 811 | on_final :sending 812 | end 813 | 814 | state :sending do 815 | on :send, :sent 816 | end 817 | 818 | state :sent do 819 | final 820 | end 821 | end 822 | 823 | def guard_transition(:planning, :building, %{wait?: true}) do 824 | {:error, "no"} 825 | end 826 | 827 | def guard_transition(:planning, :building, %{wait?: false}) do 828 | :ok 829 | end 830 | 831 | def guard_transition(_, _, _), do: :ok 832 | end 833 | 834 | test "handles null event" do 835 | assert %{state: %{name: "sending"}} = 836 | FinalStateWorkflow.new(%{wait?: false}) 837 | |> FinalStateWorkflow.transition!(:built) 838 | end 839 | 840 | test "handles final states" do 841 | assert %{state: %{name: "sending"}} = 842 | execution = 843 | FinalStateWorkflow.new(%{wait?: true}) 844 | |> FinalStateWorkflow.transition!(:start) 845 | |> FinalStateWorkflow.transition!(:built) 846 | 847 | refute FinalStateWorkflow.complete?(execution) 848 | 849 | assert %{state: %{name: "sent"}} = 850 | execution = 851 | execution 852 | |> FinalStateWorkflow.transition!(:send) 853 | 854 | assert FinalStateWorkflow.complete?(execution) 855 | end 856 | end 857 | --------------------------------------------------------------------------------