Giter Club home page Giter Club logo

state_changer's Introduction

StateChanger

Proof of Concept

A simple state machine which will change state for each transition and work for any type of data.

Motivation

You can find a lot of state machine libraries in the ruby ecosystem. All of them are great and I suggest using aasm and state_machines libraries.

But I found 3 critical problems for me which I see in all libraries and which I have no idea how to fix while using libraries:

  1. I want to use any type of data, not only ruby mutable objects. For example, I can use dry-struct, immutable entities, and good old ruby hash. In this case, I can't just inject a state machine inside an object because I can't mutate state or it's just impossible to inject something inside the object.
  2. Sometimes state transition means not only changing one filed for the state. You also need to change some fields like deleted_at, archived, or something like this. In this case, you can use it after callback or create a separate method where you'll call transition plus mutate data. But I want to see all changes which I need to do in transition in one place instead of checking transition rules + some callbacks or methods where I call transition logic.
  3. I want to control state transition on any events, It's mean that I want to use "result" object and I want to add some error messages for users if something wrong.

All these problems were a motivator for creating this library and that's why I started thinking can I use "functional approach" to make state machine better.

Philosophy

  1. The separation between state machine and data. It's mean that the state machine is not a part of the data object;
  2. Allow to determinate how exactly you want to mutate state for each transition;
  3. Make possible to detect state based on any type of data;
  4. Make it simple and dependency-free. But also, I want to implement extensions behavior for everyone who wants to use something specific;

Installation

Add this line to your application's Gemfile:

gem 'state_changer'

And then execute:

$ bundle install

Or install it yourself as:

$ gem install state_changer

Usage

Base

Base

For using StateChanger library you need to create a container object which will contain state definition and transitions:

class StateMachine < StateChanger::Base
end

All container classes don't contain global state, it's mean that you can create different state machines for one data:

class OrderStateMachine < StateChanger::Base
end

class NewOrderStateMachine < StateChanger::Base
end

Defining State

For defining specific state you need to use state method with block which should return bool value (it needs for detecting state). You can define any count of states and use any logic inside block:

class StateMachine < StateChanger::Base
  state(:open) { |hash| hash[:status] == :open }
  state(:close) { |object| object.status == :close }
  state(:inactive) { |object| object.inactive? }
end

You can also use a seporate object with all states for spliting state definition:

class States < StateChanger::StateMixin
  state(:open) { |hash| hash[:status] == :open }
  state(:close) { |object| object.status == :close }
  state(:inactive) { |object| object.inactive? }
end

class StateMachine < StateChanger::Base
  states States
end

Transition and events

For register transition in the container, you need to use register_transition method with the event name, targets, and block. In this block, you can do any manipulation with your data but state machine will return the value of block every time when you call it:

class StateMachine < StateChanger::Base
  # switch - event name for calling transition 
  # red    - initial state for transition
  # green  - ended state
  register_transition(:switch, red: :green) do |data|
    data[:light] = 'green'
    data
  end

  # Also, you can put any objects inside block:
  register_transition(:add_item, empty: :active) do |order, item|
    # ...
  end

  # Or use array as a traget
  register_transition(:add_item, [:empty, :active] => :active) do |order, item|
    # ...
  end

  register_transition(:delete_item, active: [:empty, :active]) do |order, item_id|
    # ...
  end

  # Also, you can use different targets for one event
  register_transition(:switch, red: :green)    { |data| ... }
  register_transition(:switch, green: :yellow) { |data| ... }
  register_transition(:switch, yellow: :red)   { |data| ... }
end

Execution

After defining the list of states and register transitions you can create a new instance of state machine and call specific event:

state_machine = StateMachine.new
state_machine.call(:event_name, object)
# => this call will return a new object with changed state

Also, each StateChanger container contain one event get_state which returns state of the object:

state_machine = StateMachine.new
state_machine.call(:get_state, object)
# => paid

Debugging and audit events

For debug prespective StateChanger container also sends events for each transition call. You can handle this events by adding handler logic:

class StateMachine < StateChanger::Base
  handle_event(:transited) do |transition_name, from, to, old_payload, new_payload|
    logger.info('...')
  end
end

Persist state to DB

It's a common practice to store state to DB in state machine call:

job.aasm.fire!(:run) # saved

StateChanger try to use other way and separate persist and transition logic:

# With AR
paid_order = state_machine.call(:pay, order)
paid_order.save

# With rom or hanami-model
paid_order = state_machine.call(:pay, order)
repo.update(paid_order.id, paid_order)

Traffic light example

class TrafficLightStateMachine < StateChanger::Base
  state(:red)    { |data| data[:light] == 'red' }
  state(:green)  { |data| data[:light] == 'green' }
  state(:yellow) { |data| data[:light] == 'yellow' }

  register_transition(:switch, red: :green) do |data|
    data[:light] = 'green'
    data
  end

  register_transition(:switch, green: :yellow) do |data|
    data[:light] = 'yellow'
    data
  end
  register_transition(:switch, yellow: :red) do |data|
    data[:light] = 'red'
    data
  end
end

state_machine = TrafficLightStateMachine.new
traffic_light = { street: 'B J. Comins, Licensed', light: 'red' }

new_traffic_light = state_machine.call(:switch, traffic_light)
# => { street: 'B J. Comins, Licensed', light: 'green' }

state_machine.call(:switch, new_traffic_light)
# => { street: 'B J. Comins, Licensed', light: 'yellow' }

# `state_machine.call` is pure function, it's mean that it always returns same result for the same data
state_machine.call(:switch, new_traffic_light)
# => { street: 'B J. Comins, Licensed', light: 'yellow' }

# Also, you can get state based on your data
state_machine.call(:get_state, traffic_light)
# => :red
state_machine.call(:get_state, new_traffic_light)
# => :green

Order flow example

class OrderStateMachine
  state(:empty)  { |order| order.items.empty? && order.payment.nil? }
  state(:active) { |order| order.items.any? && order.payment.nil? }
  state(:paid)   { |order| order.payment }

  register_transition(:add_item, [:empty, :active] => :active) do |order, item_id|
    order.items << item
    order
  end

  register_transition(:remove_item, active: [:empty, :active]) do |order, item_id|
    order.remove_item(item_id)
    order
  end

  register_transition(:pay, active: :paid) do |order|
    order.pay
    order
  end
end

state_machine = OrderStateMachine.new

order = Order.new(items: [])
item = { title: 'new book' }

state_machine.call(:pay, order)
# => returns error object because empty order can't be paid

active_order = state_machine.call(:add_item, order, item)
# => order with one item in 'active' state

paid_order = state_machine.call(:pay, active_order)
# => order with paid status

state_machine.call(:add_item, paid_order, item)
# => returns error again because state invalid for transition

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/davydovanton/state_changer. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the code of conduct.

License

The gem is available as open source under the terms of the MIT License.

Code of Conduct

Everyone interacting in the StateChanger project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the code of conduct.

state_changer's People

Contributors

bombazook avatar davydovanton avatar omichkun avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar

state_changer's Issues

Extending state and transitions API

Hello, I have some ideas for API extension:

  • Add ability to reference other states inside state definition
    It could be something like
state(:complete){ |c| c.state == "complete" }
state(:running){ |c| c.state == "running" }
state(:stopped){ |c| c.state == "stopped" }
state(:incomplete){ |c| running?(c) || stopped?(ั) || !c.paid? }
...
state(:completion_skipable){ |c| !fulfilled?(c) || completed?(c) || refund?(c) || deleted?(c) }

or

state(:incomplete){ |c, meta| meta.state?(:running, c) || meta.state?(:stopped, c) || !c.paid? }

or some other way

  • Add modular state definitions. It would be really nice to make composition of states.
    Seems natural to get something like traits.
    API could be like
module PreparedStates
  extend StateChanger::States
  
  state(:one) ...
  state(:n)
end

module RegisteredStates
  extend StateChanger::States
  
  state(:preparing) ...
  state(:updating)
end

class CampaignMachine < StateChanger::Base
   states RegisteredStates
end

class ExtendedCampaignMachine < StateChanger::Base
  states RegisteredStates
  states PreparedStates
end

or it could be as you described in README (nesting mixins from StateChanger::StateMixin)

  • The same thing about transitions. It looks even more natural to have different transition scopes in different situations. For example, have different transitions for different user roles.

  • It would be nice to be able to pass transitions mixins and maybe state mixins to call and transitions methods:

CampaignMachine.new.call(:destroy, campaign, transitions: AdminTransitions) # => success
CampaignMachine.new.call(:make_some_admin_action, campaign) # => failure

It differs from policy pattern because it does not choose which actions could be done by some person and doesn't change objects behaviour but limits interface. That may be really useful on retrieving transitions list:

CampaignMachine.new.transitions(campaign, transitions: LeftPanelAllowedTransitions) 
# => It will return limited list of actions for specific case
  • NoMethodError on calling state?(:not_existing_state, obj) seems like unexpected behaviour

  • It seems natural to preserve state machine behaviour on nested classes

class A < StateChanger::Base
  state(:any){ true }
end

Class.new(A).state?(:any, "any_obj") # => now it raises exception

What do you think about that?

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    ๐Ÿ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. ๐Ÿ“Š๐Ÿ“ˆ๐ŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google โค๏ธ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.