State Machines

repository·master·Indexed 21 days ago

https://github.com/state-machines/state_machines

A Ruby library for adding state machine capabilities to any Ruby class. It supports callbacks, conditional transitions, coordinated state management via state guards, and asynchronous execution using the async gem and concurrent-ruby. Features include state-driven behavior, runtime transition path analysis, and a TestHelper for Minitest and RSpec.

Tokens
11.4K
Snippets
41
Records
52
Agent score
73%

What's inside state_machines

  1. Explicit vs. Implicit Event Transitions

    master

    In state_machine, events can be triggered in two ways:

    1. Explicit Transitions: Calling the instance method generated by the event name (e.g., vehicle.ignite). This is the standard approach.
    2. Implicit Transitions: Setting a special state event attribute (e.g., vehicle.state_event = 'ignite') and then invoking the action associated with the state machine (e.g., vehicle.save). This is particularly useful for integrations like ActiveRecord or when driving transitions via a web API.

    Note: For implicit transitions, check your specific integration's API documentation for the exact attribute name used.

    # Explicit
    vehicle.ignite
    
    # Implicit (ActiveRecord example)
    vehicle.state_event = 'ignite'
    vehicle.save
  2. Coordinate multiple state machines with state guards

    master

    You can make transitions in one state machine depend on the state of another state machine within the same object using guards.

    Single State Guards

    • :if_state: Transition only if another machine is in a specific state.
    • :unless_state: Transition only if another machine is NOT in a specific state.

    Multiple State Guards

    • :if_all_states: Transition only if ALL specified machines are in their respective states.
    • :unless_all_states: Transition only if NOT ALL specified machines are in their respective states.
    • :if_any_state: Transition only if ANY of the specified machines are in their respective states.
    • :unless_any_state: Transition only if NONE of the specified machines are in their respective states.
    class TorpedoSystem
      state_machine :bay_doors, initial: :closed do
        event :open do
          transition closed: :open
        end
      end
    
      state_machine :torpedo_status, initial: :loaded do
        event :fire_torpedo do
          # Only fire if bay doors are open
          transition loaded: :fired, if_state: { bay_doors: :open }
        end
      end
    end
  3. Define transitions within State or Event contexts

    master

    Transitions can be defined in three different contexts:

    1. Event Context: Defining transitions inside an event block (most common).
    2. State Context: Defining transitions inside a state block. In this context, the from state is inferred automatically.
    3. Global Context: Defining transitions directly within the state_machine block, outside of any specific state or event. This is useful for building machines from external data stores.
    # State Context
    state :parked do
      transition to: :idling, :on => [:ignite, :shift_up], if: :seatbelt_on?
    end
    
    # Global Context
    state_machine initial: :parked do
      transition parked: :idling, :on => [:ignite, :shift_up]
      transition [:idling, :first_gear] => :parked, on: :park
      transition all - [:parked, :stalled]: :stalled, unless: :auto_shop_busy?
    end
  4. Use consistent Symbols or Strings for states and events

    master

    While you can define states and events using Strings, Symbols, or even Numbers, you must be consistent within your machine definition. Mixing types will result in an ArgumentError.

    Best Practice: Use Symbols for all states and events.

    Note on Storage: By default, all state values are stored as Strings regardless of whether you use Symbols or Strings in your definition. To store states as Symbols, you must explicitly configure the state mapping:

    states.each do |state|
      self.state(state.name, :value => state.name.to_sym)
    end

    Note on Input: When using helper methods like state?('parked') or state?(:parked), state_machine automatically maps input to match the internal type, so both String and Symbol lookups will work.

    # This will fail due to inconsistency
    class Vehicle
      state_machine do
        event :ignite do
          transition parked: 'idling' # Error: :parked is Symbol, 'idling' is String
        end
      end
    end
    
    # => ArgumentError: "idling" state defined as String, :parked defined as Symbol; all states must be consistent
  5. Install the state_machines gem

    master

    To use state machines in your Ruby application, add the gem to your Gemfile:

    gem 'state_machines'

    Then run bundle to install, or install it directly via the CLI:

    gem install state_machines

    Note: If you need to persist state in a database (e.g., using Rails, Active Record, or Mongoid), you must install the specific integration gem for your framework from the State Machines organisation.

  6. Configure dependencies for Async functionality

    master

    To use asynchronous state machine features, you must include the async and concurrent-ruby gems in your Gemfile. These are scoped to the ruby platform to avoid installation issues on JRuby or TruffleRuby.

    # Gemfile
    platform :ruby do
      gem 'async', '>= 2.25.0'
      gem 'concurrent-ruby', '>= 1.3.5'
    end
  7. Set up StateMachines::TestHelper for testing

    master

    The StateMachines::TestHelper provides expressive assertions for testing state transitions. It is not included by default and must be explicitly required and included in your test classes.

    For Minitest:

    require 'state_machines/test_helper'
    class MyTest < Minitest::Test
      include StateMachines::TestHelper
      # ...
    end

    For RSpec:

    require 'state_machines/test_helper'
    RSpec.describe MyClass do
      include StateMachines::TestHelper
      # ...
    end
  8. Use asynchronous state machines

    master

    For I/O-bound tasks, you can enable asynchronous mode by adding async: true to the state_machine declaration. This is powered by the async gem and concurrent-ruby.

    Supported Platforms:

    • MRI Ruby (CRuby) 3.2+
    • Other Ruby engines with full Fiber scheduler support.

    Note: JRuby and TruffleRuby will fall back to synchronous mode with warnings.

    When async: true is enabled, the state machine automatically generates async versions of the event methods.

    class AutonomousDrone
      state_machine :status, async: true, initial: :docked do
        event :launch do
          transition docked: :flying
        end
      end
    end
  9. Install and use state_machines dependencies

    master

    Depending on your needs, you may want to install additional gems to extend state_machines functionality:

    • Graphing: Use state_machines-graphviz to generate visual diagrams.
    • Documentation: Use state_machines-yard for YARD documentation.
    • Testing: Use state_machines-rspec to access custom RSpec matchers for testing state transitions.
  10. Create dynamic state machines at runtime

    master

    If your states, events, or transitions are not known until runtime (e.g., they are stored in a database), you can define a machine dynamically using Machine.new. This involves creating a generic class that uses state_machine inside a block to iterate over your external transition definitions.

    # Example of a dynamic machine setup
    class Vehicle
      attr_accessor :state
    
      def transitions
        [{parked: :idling, on: :ignite}]
      end
    
      def machine
        @machine ||= Machine.new(self, initial: :parked, action: :save) do |m|
          transitions.each { |attrs| m.transition(attrs) }
        end
      end
    end
    
    # The Machine helper class
    class Machine
      def self.new(object, *args, &block)
        machine_class = Class.new
        machine = machine_class.state_machine(*args, &block)
        # ... (logic to delegate attributes and actions to the object) ...
        machine_class.new
      end
    end
  11. Define a state machine in a Ruby class

    master

    Use the state_machine macro within a Ruby class to define states, events, and transitions.

    Critical Requirement: You must call super() inside your class's initialize method to ensure state machine attributes are properly initialized.

    Key features include:

    • Initial states: Set via initial: :state_name.
    • Namespacing: Use namespace: :name to avoid method collisions.
    • Callbacks: Use before_transition, after_transition, around_transition, and after_failure.
    • Conditional transitions: Use if: (procs/lambdas) or unless:.
    • Customized state values: Assign specific values to states using state :name, :value => val.
    • State-driven behavior: Define methods within state blocks to provide different implementations based on the current state.
    class Vehicle
      state_machine :state, initial: :parked do
        event :park do
          transition [:idling, :first_gear] => :parked
        end
    
        state :parked do
          def speed
            0
          end
        end
      end
    
      def initialize
        super() # REQUIRED for state initialization
      end
    end
  12. Use Async state machines

    master

    State Machines supports asynchronous event firing using the async gem. Async methods must be called within an Async block; calling them outside will raise a RuntimeError.

    Key patterns:

    • event_name_async: Returns an Async::Task. Use .wait to retrieve the result.
    • event_name_async!: A 'bang' method that returns an Async::Task but raises an error if the transition fails.
    • fire_event_async(:event_name): A generic way to fire an async event by symbol.
    Async do
      # Returns Async::Task
      task = drone.launch_async
      result = task.wait  # => true
    
      # Raises on failure
      drone.power_up_async!  
    
      # Generic async firing
      drone.fire_event_async(:teleport)
    end