diff --git a/README.md b/README.md index 09be48d..1095ea4 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,9 @@ [![CI](https://github.com/norbajunior/machinist/actions/workflows/ci.yml/badge.svg)](https://github.com/norbajunior/machinist/actions/workflows/ci.yml) [![Hex.pm Version](https://img.shields.io/hexpm/v/machinist?color=blueviolet)](https://hex.pm/packages/machinist) -This is a small library that allows you to implement finite state machines with Elixir in a simple way. It provides a simple DSL to write combinations of transitions based on events. +This is a small library that allows you to implement finite state machines +with Elixir in a simple way. It provides a simple DSL to write combinations of +transitions based on events. * [Installation](#Installation) * [Usage](#Usage) @@ -22,7 +24,10 @@ end ## Usage -A good example is how we would implement the behaviour of a door. With `machinist` would be this way: + + +A good example is how we would implement the behaviour of a door. With +`machinist` would be this way: ```elixir defmodule Door do @@ -41,7 +46,9 @@ defmodule Door do end ``` -By defining this rules with `transitions` and `from` macros, `machinist` generates and inject into the module `Door` `transit/2` functions like this one: +By defining this rules with `transitions` and `from` macros, `machinist` +generates and inject into the module `Door` `transit/2` functions like this +one: ```elixir def transit(%Door{state: :locked} = struct, event: "unlock") do @@ -49,18 +56,21 @@ def transit(%Door{state: :locked} = struct, event: "unlock") do end ``` -So that we can transit between states by relying on the **state** + **event** pattern matching. +_The functions `transit/2` implements the behaviour_ `Machinist.Transition` + +So that we can transit between states by relying on the **state** + **event** +pattern matching. Let's see this in practice: -By default our `Door` is `locked` +By default our `Door` is `locked`: ```elixir iex> door_locked = %Door{} %Door{state: :locked} ``` -So let's change its state to `unlocked` and `opened` +So let's change its state to `unlocked` and `opened`: ```elixir iex> {:ok, door_unlocked} = Door.transit(door_locked, event: "unlock") @@ -100,7 +110,9 @@ a large number of `from` definitions with a same state. ### Setting different attribute name that holds the state -By default `machinist` expects the struct being updated holds a `state` attribute, if you hold state in a different attribute, just pass the name as an atom, as follows: +By default `machinist` expects the struct being updated holds a `state` +attribute, if you hold state in a different attribute, just pass the name as an +atom, as follows: ```elixir transitions attr: :door_state do @@ -117,13 +129,18 @@ iex> Door.transit(door, event: "unlock") ### Implementing different versions of a state machine -Let's suppose we want to build a selection process app that handles applications of candidates and they may possibly going through different versions of the process. For example: +Let's suppose we want to build a selection process app that handles +applications of candidates and they may possibly going through different +versions of the process. For example: -A Selection Process **V1** with the following sequence of stages: [Registration] -> [**Code test**] -> [Enrollment] +A Selection Process **V1** with the following sequence of stages: +[Registration] -> [**Code test**] -> [Enrollment] -And a Selection Process **V2** with these ones: [Registration] -> [**Interview**] -> [Enrollment] +And a Selection Process **V2** with these ones: [Registration] -> +[**Interview**] -> [Enrollment] -The difference here is in **V1** candidates must take a **Code Test** and V2 an **Interview**. +The difference here is in **V1** candidates must take a **Code Test** and V2 an +**Interview**. So, we could have a `%Candidate{}` struct that holds these attributes: @@ -133,7 +150,11 @@ defmodule SelectionProcess.Candidate do end ``` -And a `SelectionProcess` module that implements the state machine. Notice this time we don't want to implement the rules in the module that holds the state, in this case it makes more sense the `SelectionProcess` keep the rules, also because we want more than one state machine version handling candidates as mentioned before. This is our **V1** of the process: +And a `SelectionProcess` module that implements the state machine. Notice this +time we don't want to implement the rules in the module that holds the state, +in this case it makes more sense the `SelectionProcess` keep the rules, also +because we want more than one state machine version handling candidates as +mentioned before. This is our **V1** of the process: ```elixir defmodule SelectionProcess.V1 do @@ -156,7 +177,10 @@ defmodule SelectionProcess.V1 do end ``` -In this code we pass the `Candidate` module as a parameter to `transitions` to tell `machinist` that we expect `V1.transit/2` functions with a `%Candidate{}` struct as first argument and not the `%SelectionProcess.V1{}` which would be by default. +In this code we pass the `Candidate` module as a parameter to `transitions` to +tell `machinist` that we expect `V1.transit/2` functions with a `%Candidate{}` +struct as first argument and not the `%SelectionProcess.V1{}` which would be by +default. ```elixir def transit(%Candidate{state: :new} = struct, event: "register") do @@ -164,9 +188,12 @@ def transit(%Candidate{state: :new} = struct, event: "register") do end ``` -Also notice we provided the *function* `&check_score/1` to the option `to:` instead of an *atom*, in order to decide the state based on the candidate `test_score` value. +Also notice we provided the *function* `&check_score/1` to the option `to:` +instead of an *atom*, in order to decide the state based on the candidate +`test_score` value. -In the **version 2**, we replaced the `Code Test` stage by the `Interview` which has different state transitions: +In the **version 2**, we replaced the `Code Test` stage by the `Interview` +which has different state transitions: ```elixir defmodule SelectionProcess.V2 do @@ -202,7 +229,9 @@ iex> SelectionProcess.V2.transit(candidate1, event: "schedule_interview") %{:ok, %Candidate{state: :interview_scheduled}} ``` -That's great because we also can implement many state machines for only one entity and test different scenarios, evaluate and collect data for deciding which one is better. +That's great because we also can implement many state machines for only one +entity and test different scenarios, evaluate and collect data for deciding +which one is better. `machinist` gives us this flexibility since it's just pure Elixir. @@ -210,7 +239,10 @@ That's great because we also can implement many state machines for only one enti Sometimes we need to define a `from` _any state_ transition. -Still in the selection process example, a candidate can abandon the process in a given state and we want to be able to transit him/her to `application_expired` from any state. To do so we just define a `from` with an underscore variable in order the current state to be ignored. +Still in the selection process example, a candidate can abandon the process in +a given state and we want to be able to transit him/her to +`application_expired` from any state. To do so we just define a `from` with an +underscore variable in order the current state to be ignored. ```elixir defmodule SelectionProcess.V2 do @@ -227,7 +259,9 @@ end ## How does the DSL works? -The use of `transitions` in combination with each `from` statement will be transformed in functions that will be injected into the module that is using `machinist`. +The use of `transitions` in combination with each `from` statement will be +transformed in functions that will be injected into the module that is using +`machinist`. This implementation: @@ -248,7 +282,7 @@ defmodule Door do end ``` -is the same as: +Is the same as: ```elixir defmodule Door do @@ -282,11 +316,15 @@ defmodule Door do end ``` -So, as we can see, we can eliminate a lot of boilerplate with `machinist` making it easier to maintain and less prone to errors. +So, as we can see, we can eliminate a lot of boilerplate with `machinist` +making it easier to maintain and less prone to errors. + + ## Contributing -Feel free to contribute to this lib. If you have any suggestions or bug reports just open an issue or a PR. +Feel free to contribute to this lib. If you have any suggestions or bug reports +just open an issue or a PR. ## License diff --git a/lib/machinist.ex b/lib/machinist.ex index 69a4d3d..8d64461 100644 --- a/lib/machinist.ex +++ b/lib/machinist.ex @@ -1,253 +1,12 @@ defmodule Machinist do + @external_resource "README.md" + @moduledoc """ - `Machinist` is a small library that allows you to implement finite state machines - in a simple way. It provides a simple DSL to write combinations of + `Machinist` is a small library that allows you to implement finite state + machines in a simple way. It provides a simple DSL to write combinations of transitions based on events. - A good example is how we would implement the functioning of a door. With `machinist` would be this way: - - defmodule Door do - defstruct [state: :locked] - - use Machinist - - transitions do - from :locked, to: :unlocked, event: "unlock" - from :unlocked, to: :locked, event: "lock" - from :unlocked, to: :opened, event: "open" - from :opened, to: :closed, event: "close" - from :closed, to: :opened, event: "open" - from :closed, to: :locked, event: "lock" - end - end - - By defining this rules with `transitions` and `from` macros, `machinist` generates and inject into the module `Door` `transit/2` functions like this one: - - def transit(%Door{state: :locked} = struct, event: "unlock") do - {:ok, %Door{struct | state: :unlocked}} - end - - _The functions `transit/2` implements the behaviour_ `Machinist.Transition` - - So that we can transit between states by relying on the **state** + **event** pattern matching. - - Let's see this in practice: - - By default our `Door` is `locked` - - iex> door_locked = %Door{} - iex> %Door{state: :locked} - - So let's change its state to `unlocked` and `opened` - - iex> {:ok, door_unlocked} = Door.transit(door_locked, event: "unlock") - iex> {:ok, %Door{state: :unlocked}} - iex> {:ok, door_opened} = Door.transit(door_unlocked, event: "open") - iex> {:ok, %Door{state: :opened}} - - If we try to make a transition that not follow the rules, we got an error: - - - iex> Door.transit(door_opened, event: "lock") - iex> {:error, :not_allowed} - - ### Group same-state `from` definitions - - In the example above we also could group the `from :unlocked` definitions like this: - - # ... - transitions do - from :locked, to: :unlocked, event: "unlock" - from :unlocked do - to :locked, event: "lock" - to :opened, event: "open" - end - from :opened, to: :closed, event: "close" - from :closed, to: :opened, event: "open" - from :closed, to: :locked, event: "lock" - end - # ... - - This is an option for a better organization and an increase of readability when having - a large number of `from` definitions with a same state. - - ### Setting different attribute name that holds the state - - By default `machinist` expects the struct being updated holds a `state` attribute, - if you hold state in a different attribute, just pass the name as an atom, as follows: - - - transitions attr: :door_state do - # ... - end - - And then `machinist` will set state in that attribute - - - iex> Door.transit(door, event: "unlock") - iex> {:ok, %Door{door_state: :unlocked}} - - ### Implementing different versions of a state machine - - Let's suppose we want to build a selection process app that handles applications - of candidates and they may possibly going through different versions of the process. For example: - - A Selection Process **V1** with the following sequence of stages: [Registration] -> [**Code test**] -> [Enrollment] - - And a Selection Process **V2** with these ones: [Registration] -> [**Interview**] -> [Enrollment] - - The difference here is in **V1** candidates must take a **Code Test** and V2 an **Interview**. - - So, we could have a `%Candidate{}` struct that holds these attributes: - - - defmodule SelectionProcess.Candidate do - defstruct [:name, :state, test_score: 0] - end - - And a `SelectionProcess` module that implements the state machine. - Notice this time we don't want to implement the rules in the module that holds - the state, in this case it makes more sense the `SelectionProcess` keep the rules, - also because we want more than one state machine version handling candidates as mentioned before. - This is our **V1** of the process: - - defmodule SelectionProcess.V1 do - use Machinist - - alias SelectionProcess.Candidate - - @minimum_score 100 - - transitions Candidate do - from :new, to: :registered, event: "register" - from :registered, to: :started_test, event: "start_test" - from :started_test, to: &check_score/1, event: "send_test" - from :approved, to: :enrolled, event: "enroll" - end - - defp check_score(%Candidate{test_score: score}) do - if score >= @minimum_score, do: :approved, else: :reproved - end - end - - In this code we pass the `Candidate` module as a parameter to `transitions` - to tell `machinist` that we expect `V1.transit/2` functions with a `%Candidate{}` - struct as first argument and not the `%SelectionProcess.V1{}` which would be by default. - - def transit(%Candidate{state: :new} = struct, event: "register") do - {:ok, %Candidate{struct | state: :registered}} - end - - Also notice we provided the *function* `&check_score/1` to the option `to:` instead of an *atom*, in order to decide the state based on the candidate `test_score` value. - - In the **version 2**, we replaced the `Code Test` stage by the `Interview` which has different state transitions: - - defmodule SelectionProcess.V2 do - use Machinist - - alias SelectionProcess.Candidate - - transitions Candidate do - from :new, to: :registered, event: "register" - from :registered, to: :interview_scheduled, event: "schedule_interview" - from :interview_scheduled, to: :approved, event: "approve_interview" - from :interview_scheduled, to: :repproved, event: "reprove_interview" - from :approved, to: :enrolled, event: "enroll" - end - end - - Now let's see how this could be used: - - **V1:** A `registered` candidate wants to start its test. - - iex> candidate1 = %Candidate{name: "Ada", state: :registered} - iex> SelectionProcess.V1.transit(candidate1, event: "start_test") - iex> %{:ok, %Candidate{state: :test_started}} - - **V2:** A `registered` candidate wants to schedule the interview - - iex> candidate2 = %Candidate{name: "Jose", state: :registered} - iex> SelectionProcess.V2.transit(candidate1, event: "schedule_interview") - iex> %{:ok, %Candidate{state: :interview_scheduled}} - - That's great because we also can implement many state machines for only one - entity and test different scenarios, evaluate and collect data for deciding which one is better. - - `machinist` gives us this flexibility since it's just pure Elixir. - - ### Transiting from any state to another - - Sometimes we need to define a `from` _any state_ transition. - - Still in the selection process example, a candidate can abandon the process in a given state and we want to be able to transit him/her to `application_expired` from any state. To do so we just define a `from` with an underscore variable in order the current state to be ignored. - - defmodule SelectionProcess.V2 do - use Machinist - - alias SelectionProcess.Candidate - - transitions Candidate do - # ... - from _state, to: :application_expired, event: "application_expired" - end - end - - ## How does the DSL works? - - The use of `transitions` in combination with each `from` statement will be - transformed in functions that will be injected into the module that is using `machinist`. - - This implementation: - - defmodule Door do - defstruct state: :locked - - use Machinist - - transitions do - from :locked, to: :unlocked, event: "unlock" - from :unlocked, to: :locked, event: "lock" - from :unlocked, to: :opened, event: "open" - from :opened, to: :closed, event: "close" - from :closed, to: :opened, event: "open" - from :closed, to: :locked, event: "lock" - end - end - - is the same as: - - defmodule Door do - defstruct state: :locked - - def transit(%__MODULE__{state: :locked} = struct, event: "unlock") do - {:ok, %__MODULE__{struct | state: :unlocked}} - end - - def transit(%__MODULE__{state: :unlocked} = struct, event: "lock") do - {:ok, %__MODULE__{struct | state: :locked}} - end - - def transit(%__MODULE__{state: :unlocked} = struct, event: "open") do - {:ok, %__MODULE__{struct | state: :opened}} - end - - def transit(%__MODULE__{state: :opened} = struct, event: "close") do - {:ok, %__MODULE__{struct | state: :closed}} - end - - def transit(%__MODULE__{state: :closed} = struct, event: "open") do - {:ok, %__MODULE__{struct | state: :opened}} - end - - def transit(%__MODULE__{state: :closed} = struct, event: "lock") do - {:ok, %__MODULE__{struct | state: :locked}} - end - # a catchall function in case of unmatched clauses - def transit(_, _), do: {:error, :not_allowed} - end - - So, as we can see, we can eliminate a lot of boilerplate with `machinist` making - it easier to maintain and less prone to errors. + #{File.read!("README.md") |> String.split("") |> Enum.fetch!(1)} """ @doc false