Machinery

repository·master·Indexed 20 days ago

https://github.com/joaomdmoura/machinery

A lightweight State Machine library for Elixir structs (version 1.1.0) that provides a DSL for declaring states, transitions, guards, and callbacks. It is designed for easy integration with Phoenix and Ecto models, supporting features such as guard clauses, persistence callbacks, and transition logging.

Tokens
2.6K
Snippets
11
Records
11
Agent score
69%

What's inside machinery

  1. Install Machinery via mix

    master

    Add :machinery to your mix.exs dependencies to use the library in your Elixir project.

    When using with a struct, ensure the struct has a field to hold the state (e.g., :state). If using Phoenix/Ecto, add the field as a :string to your schema and include it in your changeset/2 function.

    def deps do
      [
        {:machinery, "~> 1.1.0"}
      ]
    end
  2. Configure a state machine using `use Machinery`

    master

    To define a state machine, use the Machinery module within your state machine module. You must provide a Keyword list containing states and transitions.

    • states: A list of strings representing all possible states. The first state in this list is automatically treated as the initial state.
    • transitions: A map where keys are current states and values are either a single string (the next state) or a list of strings (allowed next states).
    • field (optional): An option to specify which field in your struct holds the state. Defaults to :state.
    defmodule YourProject.UserStateMachine do
      use Machinery,
        states: ["created", "partial", "complete"],
        transitions: %{
          "created" => ["partial", "complete"],
          "partial" => "completed"
        }
    end
  3. Use log_transition/2 or log_transition/3 for auditing

    master

    To log every successful state change, implement log_transition/2 or log_transition/3 in your state machine module. This callback is executed after the persist function has successfully run.

    • The function receives the unchanged struct and the next_state string.
    • Requirement: The function must return the struct.

    Example

    defmodule YourProject.UserStateMachine do
      use Machinery, ...
    
      def log_transition(struct, next_state) do
        IO.puts("Transitioned to #{next_state}")
        struct
      end
    end
    def log_transition(struct, _next_state) do
      # Log transition here.
      # ...
      # `log_transition` should always return the struct
      struct
    end
  4. Implement the persist/2 or persist/3 callback to save state

    master

    To ensure state changes are saved (e.g., to a database), implement a persist/2 or persist/3 function in your state machine module.

    • persist/2 receives (struct, next_state).
    • persist/3 receives (struct, next_state, extra_metadata).

    Requirement: The function must return the updated struct.

    Example

    defmodule YourProject.UserStateMachine do
      use Machinery,
        states: ["created", "completed"],
        transitions: %{"created" => "completed"}
      
      def persist(struct, next_state) do
        # Example: Updating a database via an external module
        {:ok, user} = Accounts.update_user(struct, %{state: next_state})
        user
      end
    end
    def persist(struct, next_state) do
      # Updating a user on the database with the new state.
      {:ok, user} = Accounts.update_user(struct, %{state: next_state})
      # `persist` should always return the updated struct
      user
    end
  5. Use before_transition and after_transition callbacks for side effects

    master

    You can execute logic before or after a state change by implementing before_transition/2 (or /3) and after_transition/2 (or /3).

    • Use pattern matching on the second argument to target specific states.
    • Requirement: Both functions must return the struct.

    Example

    defmodule YourProject.UserStateMachine do
      use Machinery, ...
    
        def before_transition(struct, "partial") do
          # Perform side effects before entering 'partial' state
          struct
        end
    
        def after_transition(struct, "completed") do
          # Perform side effects after entering 'completed' state
          struct
        end
    end
    def before_transition(struct, "state"), do: struct
    def after_transition(struct, "state"), do: struct
  6. Declare a State Machine using the Machinery DSL

    master

    To define state machine logic, create a dedicated module and use the Machinery macro. The macro accepts a keyword list with the following keys:

    • field: An atom representing the field name in your struct that stores the state (defaults to :state).
    • states: A list of strings representing all valid states.
    • transitions: A map where keys are current states and values are either a single string (the next state) or a list of strings (allowed next states).

    You can use the wildcard "*" as a key in the transitions map to allow a transition from any current state to a specific target state.

    Example

    defmodule YourProject.UserStateMachine do
      use Machinery,
        field: :custom_state_name,
        states: ["created", "partial", "completed", "canceled"],
        transitions: %{
          "created" =>  ["partial", "completed"],
          "partial" => "completed",
          "*" => "canceled"
        }
    end
  7. Use guard_transition/2 or guard_transition/3 to validate transitions

    master

    Guard functions allow you to prevent transitions based on the current state of the struct. Implement guard_transition/2 or guard_transition/3 in your state machine module.

    • The second argument is used to pattern match the target state.
    • To allow the transition: Return anything other than {:error, "cause"} (e.g., true, {:ok, ...}, or the struct itself).
    • To block the transition: Return {:error, "cause"}.

    If a guard fails, Machinery.transition_to/3 returns {:error, "cause"}.

    Example

    defmodule YourProject.UserStateMachine do
      use Machinery,
        states: ["created", "completed"],
        transitions: %{"created" => "completed"}
    
      def guard_transition(struct, "completed") do
        if Map.get(struct, :missing_fields) == true do
          {:error, "There are missing fields"}
        else
          true
        end
      end
    end
    def guard_transition(struct, "completed") do
      if Map.get(struct, :missing_fields) == true do
        {:error, "There are missing fields"}
      end
    end
  8. Transition a struct to a new state with transition_to/3 and transition_to/4

    master

    Use Machinery.transition_to/3 or Machinery.transition_to/4 to trigger a state change on a struct.

    Arguments:

    • struct: The struct instance to transition.
    • state_machine_module: The module where Machinery was used to define the logic.
    • next_event: A string representing the target state.
    • extra_metadata (optional): A map containing additional data that can be passed to callbacks (like persist/3, log_transition/3, etc.).

    Returns:

    • {:ok, updated_struct} on success.
    • {:error, "cause"} if a guard clause fails.

    Example

    # Basic transition
    {:ok, updated_user} = Machinery.transition_to(user, UserStateMachine, "completed")
    
    # Transition with metadata
    {:ok, updated_user} = Machinery.transition_to(user, UserStateMachine, "completed", %{extra: "metadata"})
    Machinery.transition_to(your_struct, YourStateMachine, "next_state")
    # {:ok, updated_struct}
    
    # OR
    
    Machinery.transition_to(your_struct, YourStateMachine, "next_state", %{extra: "metadata"})
    # {:ok, updated_struct}
  9. Transition a struct to a new state with `Machinery.transition_to/4`

    master

    Use Machinery.transition_to/4 to attempt a state change on a struct. The transition will only succeed if the move is allowed by the transitions map defined in your state machine module and if any guard functions pass.

    Parameters:

    • struct: The struct instance you want to transition.
    • state_machine_module: The module where you called use Machinery.
    • next_state: A string representing the target state.
    • extra_metadata (optional): A map containing additional data accessible within Machinery callbacks, guards, logs, or persistence functions.

    Returns:

    • {:ok, updated_struct}: If the transition was successful.
    • {:error, "reason"}: If the transition failed.

    Note: Ensure you have started the Machinery supervisor using Machinery.start/2 before calling this function.

    # Basic transition
    Machinery.transition_to(%User{state: "partial"}, UserStateMachine, "completed")
    # Returns: {:ok, %User{state: "completed"}}
    
    # Transition with extra metadata
    Machinery.transition_to(%User{state: "partial"}, UserStateMachine, "completed", %{verified: true})
    # Returns: {:ok, %User{state: "completed"}}
  10. Reference: `Machinery.__using__/1` options

    master

    When using use Machinery, opts, the following keys are supported in the opts keyword list:

    # Supported keys in the opts keyword list:
    # :states       - A List of Strings representing each state.
    # :transitions  - A Map for each state and its allowed next state(s).
    # :field        - The struct field used to store the state (defaults to :state).