defmodule Statex do @moduledoc """ A behaviour module for implementing finite state machines. Statex is a wrapper around an `Agent` holding a map (or keyword or struct) type structure in its state. Importantly one key, called `:state`, is used to hold the state of the state machine, which is constrained to the specified graph. Statex provides facilities to manipulate other data (attributes) that are being held in the agent's state, hook functions which will trigger upon state transitions, filters to have more fine-grained control over transitions that might gate on attributes, and a method to gracefully shut itself down. ## The state graph The state graph is specified using the the (required) `@state_graph` attribute. ### Example: defmodule LightSwitch do use Statex @state_graph on: [flip: :off], off: [flip: :on] end {:ok, pid} = LightSwitch.start_link LightSwitch.state(pid) # ==> :on LightSwitch.flip(pid) # ==> :ok LightSwitch.state(pid) # ==> :off This is sufficient to create a light switch state machine. The `:flip` transition is automatically turned into the `LightSwitch.flip/1` method. By default, the first state in the state list is the initial state, if you seek to override this behavior, set the `@initial_state` module attribute. If you would like to document the transition functions, use the `@graphdoc` module attribute. This should be a keyword list mapping the transitions to their respective documentation text, e.g.: @graphdoc flip: "flips the switch" might be appropriate documentation for the above module. ## Attributes you may set attributes for the state machine, using provided getter/setter methods. This can include descriptions that can elaborate on the chores to be done on transitions, or counters that increment as certain states are passed. The following functions are available for accessing attribute data: - `put/3` - modifies the attributes, adding a key, value pair - `get/2` - retrieves an attribute by key - `merge/2` - merges another map's values into the attributes - `drop/2` - returns an attribute map, but with a list of keys dropped. - `take/2` - returns an attribute map including only specified keys. ### Example {:ok, pid} = LightSwitch.start_link(initial_attribs: %{foo: "bar"}) LightSwitch.get(pid, :foo) #==> "bar" LightSwitch.put(pid, :baz, :quux) LightSwitch.take(pid, [:foo, :baz]) #==> %{foo: "bar", baz: :quux} ## Transition filters Transition filters can set a filter beyond what is allowed in the simple state graph that can be dependent on transient or constant state machine attributes. A filter function should return **true** if the transition should be **rejected**. You may program lazily, and use function guards in such a fashion that they implicitly return false if no matching clauses are found. Filter functions take two parameters - the current attribute map of the state machine, and the proposed transition. The following filter function will allow the `move()` transition when the `:color` attribute is `:yellow` or `:green` (or even `"red"`) for a state machine bound to a standard Map object, effectively an attribute-dependent blacklist: fn %{color: :red}, :move -> true end If you wanted the inverse behavior, where it acts as a whitelist, structure your function as follows: fn %{color: :green}, :move -> false _ , :move -> true end In the current incarnation, transition filters will trigger a fail if **any** of the functions returns true; this may change and an order of precedence that is dependent on an index or on the add order may be considered in the future. ### Example defmodule BlockableFlipFlop do use Statex @state_graph on: [flip: :off], off: [flip: :on] @initial_filters [&BlockableFlipFlop.filter/2] def filter(%{blocked: value}, _ ), do: value end {:ok, pid} = BlockableFlipFlop.start_link BlockableFlipFlop.state(pid) # ==> :on BlockableFlipFlop.flip(pid) # ==> :ok BlockableFlipFlop.state(pid) # ==> :off BlockableFlipFlop.put(pid, :blocked, true) BlockableFlipFlop.flip(pid) # ==> {:error, "invalid transition"} BlockableFlipFlop.state(pid) # ==> :off BlockableFlipFlop.put(pid, :blocked, false) BlockableFlipFlop.flip(pid) # ==> :ok BlockableFlipFlop.state(pid) # ==> :on ### Usage Notes Typically filters will be set using the `@initial_filters` module attribute which will populate the filter list at compile-time. though filters may be dynamically added later using `set_transition_filter/2`. Adding filters at runtime is only recommended if tasks for transient state machines share the same overall state graph with slightly different behavior that need to be set at server instantiation, though in most cases even this can be handled with static filters, function guards on attributes, and setting attributes when the state server is created. If you're implementing a custom backing engine (see below), your datatype will be converted to a map before being applied to the filter function. ## Transition triggers A key part of executing a state machine is being able to perform actions when a transition is requested. This is accomplished using trigger functions. fn state, :move -> new_value = do_something(...) %{attribute_to_change: new_value} end The output of the transition function is a map of changes to be made to the current state. `Statex` does not guarantee execution order of these functions, so transition functions which have competing state changes may have unresolvable collisions. if details of the trigger action depends on the current state of the machine, you can access this by carefully matching the :state attribute in the map, for example: fn %{state: :state1}, :move -> do_something_for_state1(...) %{} %{state: :state2}, :move -> do_something_for_state2(...) %{} end **NB** use this carefully, you can cause problems by assigning the `:state` parameter directly. If a transition trigger causes a fail condition, instead use the `fail_transition/1` function; this will bail out of all of the triggers, leave state attributes unchanged, followed by a transition through the passed parameter. ### Example: defmodule LightSwitch do use Statex @state_graph on: [flip: :off, fail: :error], off: [flip: :on, fail: :error], error: [] @initial_triggers [&LightSwitch.trigger/2] def trigger(%{breakme: true}, _) do fail_transition(:fail) end end {:ok, pid} = LightSwitch.start_link resp_pid = self() LightSwitch.set_transition_trigger(pid, fn %{}, :flip -> send(resp_pid, :flipped) %{} end) LightSwitch.flip(pid) receive do :flipped -> :ok end # passes the gate. LightSwitch.state(pid) # ==> :off LightSwitch.put(pid, :breakme, true) LightSwitch.flip(pid) # ==> {:error, "error while transitioning"} LightSwitch.state(pid) # ==> :error ### Usage Notes Typically triggers will be set using the `@initial_triggers` module attribute which will populate the trigger list at compile-time. though triggers may be dynamically added later using `set_transition_trigger/2`. Adding triggers at runtime is useful for creating a callback scenario where a response is not always needed (for example, if a user can request a task completion notification gated at a certain point in the state graph, but it defaults to not notifying). If you're using a custom backing engine, the trigger function first parameter will be matched on the *Map* translation of whatever data structure is held internally. ## Custom backing engines you may also pass the :engine parameter to `use Statex` which will assign an engine. This will allow you to, for example, back state and state attributes against a database. **NB**: you might have to `require CUSTOMMODULE` in order to use this feature. Builtin support for `Map`, `Keyword`, and any module defining a `struct` is provided. If you choose to implement your own module, the custom engine module *must* implement the following functions in order to be used as a backing module. See `Map` for references to what the parameters of these functions should be: - `new/0` - emits a blank example of the datatype - `put/3` - modifies an example of the datatype, adding a key, value pair - `get/2` - retrieves a value from the datatype based on a key - `to_map/1` - converts the datatype to a map The following functions have fallback implementations, but will use the module's implementations if present: - `merge/2` - merges another map's values into the dataype - `drop/2` - returns a map, but with a list of keywords dropped. - `take/2` - returns a map of the datastructure, but only with certain keywords - `from_map/1` - converts a map to the datatype ### Examples defmodule MyStruct do defstruct data: nil, more_data: nil end defmodule MyStateMachine do use Statex, engine: MyStruct @state_graph #... end ################################### defmodule MyCustomEngine do def new(), do: #... def put(data, key, val), do: #... def get(data, key), do: #... def from_map(map), do: #... end defmodule MyStateMachine do use Statex, engine: MyCustomEngine @state_graph #... end ## Overriding new, and updates In some cases you may want to trap whenever the attribute list gets created or updated, for example, if the attributes of the state machine are to be backed by a database. In these cases you should override the `on_new/1` and `on_update/1` methods. ### Example defmodule SimpleState do use Statex @state_graph start: [move: :done], done: [] @impl true def on_update(map = %{dest: pid}) do send(pid, {:resp, map}) map end @impl true def on_new(map = %{dest: pid}) do send(pid, {:resp, map}) map end end {:ok, pid} = SimpleState.start_link(initial_attribs: %{dest: self()}) receive do {:resp, map} -> map end # ==> %{dest: ...} SimpleState.put(pid, :foo, "bar") receive do {:resp, map} -> map end # ==> %{state: :start, foo: "bar"...} SimpleState.move(pid) receive do {:resp, map} -> map end # ==> %{state: :done, foo: "bar"...} """ alias Statex.StateGraph # helper modules that provide specific functions alias Statex.Helpers.Accessor alias Statex.Helpers.Filter alias Statex.Helpers.Property alias Statex.Helpers.Stop alias Statex.Helpers.Transition alias Statex.Helpers.Trigger # typedefs @typedoc """ the type defining transition triggers transition triggers match on the current attribs and the proposed transition, returning any changes to be made to the attribs. In general, this change should not include the `:state` attribute. When being called a trigger function does not need to cover all match cases, a mismatch is ignored by the trigger processing mechanism. """ @type trigger()::(attribs::map, transition::atom -> new_attribs::map) @typedoc """ the type defining transition filters transition filters match on the current attribs and the proposed transition, returning true if the transition should be rejected, and false if it should not. When being called, a filter function does not need to cover all match cases, a mismatch is ignored by the filter processing mechanism. """ @type filter()::(attribs::map, transition::atom -> reject::boolean) # optional callbacks that allow for overrides. @doc """ (optional) allows intercepting the attribute datastructure creation event. **NB** this does not take the `map` datatype, but rather takes the custom datatype that is used by the backing engine. In particular, for `struct` backing engines, this will be the datatype: `{%STRUCT{}, map}` defaults to identity with no side effects. """ @callback on_new(attribs::any) :: any @doc """ (optional) allows intercepting the attribute datastructure modification events. **NB** this does not take the `map` datatype, but rather takes the custom datatype that is used by the backing engine. In particular, for `struct` backing engines, this will be the datatype: `{%STRUCT{}, map}` defaults to identity with no side effects. """ @callback on_update(attribs::any) :: any ############################################################################## # # using macro is invoked when the use Statex command is issued at the top # of the module definition. This initializes all the reserved module # attributes. defmacro __using__(opts) do backing_engine = opts[:engine] unless Accessor.has_required_functions?(backing_engine, __CALLER__.context_modules) do raise Statex.Helpers.PluginError end accessor_functions = Accessor.functions(backing_engine, __CALLER__.context_modules) property_functions = Property.functions transition_functions = Transition.functions trigger_functions = Trigger.functions filter_functions = Filter.functions stop_functions = Stop.functions quote location: :keep do use Agent, unquote(opts) @behaviour Statex alias Statex.StateGraph # internally useful module values @statex_engine__ unquote(backing_engine) || Map @before_compile {Statex, :generate_statex_functions} @drop_fns__ [:__filters__, :__triggers__, :__finalizers__] # module values that might be @initial_state nil @initial_filters nil @initial_triggers nil unquote(accessor_functions) unquote(property_functions) unquote(transition_functions) unquote(filter_functions) unquote(trigger_functions) unquote(stop_functions) @spec state(Agent.agent) :: atom @doc """ retrieves the current `:state` attribute of the state machine. """ def state(sm), do: get(sm, :state) @spec on_update(any) :: any def on_update(v), do: v @spec on_new(any) :: any def on_new(v), do: v defoverridable on_update: 1, on_new: 1 end end @doc false defmacro generate_statex_functions(env) do state_graph = Module.get_attribute(env.module, :state_graph) StateGraph.validate_graph_structure!(state_graph) docs = Module.get_attribute(env.module, :graphdoc) transitionfn_list = Transition.generate_calls(state_graph, docs) quote do unquote_splicing(transitionfn_list) defp initial_state, do: @initial_state defp state_graph, do: @state_graph @doc """ Starts a state machine linked to the current process with the given function. This is often used to start the agent as part of a supervision tree. #### Options The `:initial_attribs` option (a map) lets you set the initial attributes """ def start_link(options \\ []) do initial_attribs = options[:initial_attribs] || %{} start_state = @initial_state || StateGraph.first_state(@state_graph) %{ __triggers__: @initial_triggers || [], __filters__: @initial_filters || [], state: start_state } |> Map.merge(initial_attribs) |> start_internal(options) end end end end