├── .rspec ├── lib ├── state_changer │ ├── version.rb │ ├── container.rb │ └── base.rb └── state_changer.rb ├── .travis.yml ├── Rakefile ├── spec ├── state_changer_spec.rb ├── spec_helper.rb ├── helpers │ └── traffic_light.rb ├── container_spec.rb └── base_spec.rb ├── bin ├── setup └── console ├── Gemfile ├── .gitignore ├── Gemfile.lock ├── LICENSE.txt ├── examples └── traffic_light.rb ├── state_changer.gemspec ├── CODE_OF_CONDUCT.md └── README.md /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | --require spec_helper 4 | -------------------------------------------------------------------------------- /lib/state_changer/version.rb: -------------------------------------------------------------------------------- 1 | module StateChanger 2 | VERSION = "0.0.0" 3 | end 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | --- 2 | language: ruby 3 | cache: bundler 4 | rvm: 5 | - 2.7.0 6 | before_install: gem install bundler -v 2.1.4 7 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rspec/core/rake_task" 3 | 4 | RSpec::Core::RakeTask.new(:spec) 5 | 6 | task :default => :spec 7 | -------------------------------------------------------------------------------- /spec/state_changer_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe StateChanger do 2 | it "has a version number" do 3 | expect(StateChanger::VERSION).not_to be nil 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | # Specify your gem's dependencies in state_changer.gemspec 4 | gemspec 5 | 6 | gem "rake", "~> 12.0" 7 | gem "rspec", "~> 3.0" 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | 10 | # rspec failure tracking 11 | .rspec_status 12 | -------------------------------------------------------------------------------- /lib/state_changer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'state_changer/version' 4 | require 'state_changer/base' 5 | 6 | module StateChanger 7 | class WrongStateError < StandardError; end 8 | class WrongTransitionError < StandardError; end 9 | end 10 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "state_changer" 5 | 6 | # You can add fixtures and/or initialization code here to make experimenting 7 | # with your gem easier. You can also use a different console, if you like. 8 | 9 | # (If you use this, don't forget to add pry to your Gemfile!) 10 | # require "pry" 11 | # Pry.start 12 | 13 | require "irb" 14 | IRB.start(__FILE__) 15 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require "bundler/setup" 2 | require "state_changer" 3 | 4 | RSpec.configure do |config| 5 | # Enable flags like --only-failures and --next-failure 6 | config.example_status_persistence_file_path = ".rspec_status" 7 | 8 | # Disable RSpec exposing methods globally on `Module` and `main` 9 | config.disable_monkey_patching! 10 | 11 | config.expect_with :rspec do |c| 12 | c.syntax = :expect 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /spec/helpers/traffic_light.rb: -------------------------------------------------------------------------------- 1 | class TrafficLightStateMachine < StateChanger::Base 2 | state(:red) { |data| data[:light] == 'red' } 3 | state(:green) { |data| data[:light] == 'green' } 4 | state(:yellow) { |data| data[:light] == 'yellow' } 5 | 6 | register_transition(:switch, red: :green) do |data| 7 | data[:light] = 'green' 8 | data 9 | end 10 | 11 | register_transition(:switch, green: :yellow) do |data| 12 | data[:light] = 'yellow' 13 | data 14 | end 15 | 16 | register_transition(:switch, yellow: :red) do |data| 17 | data[:light] = 'red' 18 | data 19 | end 20 | end 21 | 22 | -------------------------------------------------------------------------------- /lib/state_changer/container.rb: -------------------------------------------------------------------------------- 1 | module StateChanger 2 | class Container 3 | def initialize 4 | @container = {} 5 | end 6 | 7 | def register(key, value = nil, meta = {}, &block) 8 | if value 9 | @container[[key.to_s, meta]] = value 10 | else 11 | @container[[key.to_s, meta]] = block 12 | end 13 | end 14 | 15 | def [](key, meta: {}) 16 | full_key = @container.keys.detect { |k| k.first == key.to_s } 17 | @container[full_key] 18 | end 19 | 20 | def keys 21 | @container.keys.map(&:first) 22 | end 23 | 24 | def full_keys 25 | @container.keys 26 | end 27 | 28 | def get_by_full_key(key) 29 | @container[key] 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | state_changer (0.0.0) 5 | 6 | GEM 7 | remote: https://rubygems.org/ 8 | specs: 9 | diff-lcs (1.3) 10 | rake (12.3.3) 11 | rspec (3.9.0) 12 | rspec-core (~> 3.9.0) 13 | rspec-expectations (~> 3.9.0) 14 | rspec-mocks (~> 3.9.0) 15 | rspec-core (3.9.2) 16 | rspec-support (~> 3.9.3) 17 | rspec-expectations (3.9.2) 18 | diff-lcs (>= 1.2.0, < 2.0) 19 | rspec-support (~> 3.9.0) 20 | rspec-mocks (3.9.1) 21 | diff-lcs (>= 1.2.0, < 2.0) 22 | rspec-support (~> 3.9.0) 23 | rspec-support (3.9.3) 24 | 25 | PLATFORMS 26 | ruby 27 | 28 | DEPENDENCIES 29 | rake (~> 12.0) 30 | rspec (~> 3.0) 31 | state_changer! 32 | 33 | BUNDLED WITH 34 | 2.1.4 35 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 Anton Davydov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /examples/traffic_light.rb: -------------------------------------------------------------------------------- 1 | require_relative './../lib/state_changer' 2 | 3 | class TrafficLightStateMachine < StateChanger::Base 4 | state(:red) { |data| data[:light] == 'red' } 5 | state(:green) { |data| data[:light] == 'green' } 6 | state(:yellow) { |data| data[:light] == 'yellow' } 7 | 8 | register_transition(:switch, red: :green) do |data| 9 | data[:light] = 'green' 10 | data 11 | end 12 | 13 | register_transition(:switch, green: :yellow) do |data| 14 | data[:light] = 'yellow' 15 | data 16 | end 17 | register_transition(:switch, yellow: :red) do |data| 18 | data[:light] = 'red' 19 | data 20 | end 21 | end 22 | 23 | puts 'Start a new example' 24 | state_machine = TrafficLightStateMachine.new 25 | traffic_light = { street: 'B J. Comins, Licensed', light: 'red' } 26 | 27 | p traffic_light 28 | 29 | new_traffic_light = state_machine.call(:switch, traffic_light) 30 | 31 | p new_traffic_light 32 | 33 | p state_machine.call(:switch, new_traffic_light) 34 | p state_machine.call(:switch, new_traffic_light) 35 | 36 | # state_machine.call(:get_state, traffic_light) 37 | # state_machine.call(:get_state, new_traffic_light) 38 | -------------------------------------------------------------------------------- /state_changer.gemspec: -------------------------------------------------------------------------------- 1 | require_relative 'lib/state_changer/version' 2 | 3 | Gem::Specification.new do |spec| 4 | spec.name = "state_changer" 5 | spec.version = StateChanger::VERSION 6 | spec.authors = ["Anton Davydov"] 7 | spec.email = ["antondavydov.o@gmail.com"] 8 | 9 | spec.summary = %q{The state machine for change your data between states.} 10 | spec.description = %q{The state machine for change your data between states.} 11 | spec.homepage = "https://github.com/davydovanton/state_changer" 12 | spec.license = "MIT" 13 | spec.required_ruby_version = Gem::Requirement.new(">= 2.3.0") 14 | 15 | spec.metadata["homepage_uri"] = spec.homepage 16 | spec.metadata["source_code_uri"] = "https://github.com/davydovanton/state_changer" 17 | # spec.metadata["changelog_uri"] = "TODO: Put your gem's CHANGELOG.md URL here." 18 | 19 | # Specify which files should be added to the gem when it is released. 20 | # The `git ls-files -z` loads the files in the RubyGem that have been added into git. 21 | spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do 22 | `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 23 | end 24 | spec.bindir = "exe" 25 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 26 | spec.require_paths = ["lib"] 27 | end 28 | -------------------------------------------------------------------------------- /spec/container_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe StateChanger::Container do 2 | let(:container) { described_class.new } 3 | 4 | describe '#register' do 5 | it 'registers new objects' do 6 | container.register(:key1, Object.new) 7 | container.register(:key2, Object.new) 8 | container.register(:key3) { |data| puts data } 9 | 10 | expect(container.keys).to eq(['key1', 'key2', 'key3']) 11 | end 12 | end 13 | 14 | describe '#[]' do 15 | subject { container[key] } 16 | 17 | context 'when value is block' do 18 | let(:key) { :state } 19 | 20 | it 'returns block as a value' do 21 | container.register(:state) { |data| [data] } 22 | 23 | expect(subject).to be_a(Proc) 24 | expect(subject.call(1)).to eq([1]) 25 | end 26 | end 27 | 28 | context 'when key is symbol and registered key is symbol' do 29 | let(:key) { :state } 30 | 31 | it 'returns value for key' do 32 | container.register(:state, 'red') 33 | 34 | expect(subject).to eq('red') 35 | end 36 | end 37 | 38 | context 'when key is string and registered key is symbol' do 39 | let(:key) { 'state' } 40 | 41 | it 'returns value for key' do 42 | container.register(:state, 'red') 43 | 44 | expect(subject).to eq('red') 45 | end 46 | end 47 | 48 | end 49 | 50 | describe '#keys' do 51 | subject { container.keys } 52 | 53 | it 'returns all registered keys from container' do 54 | container.register(:key1, Object.new) 55 | container.register(:key2, Object.new) 56 | 57 | expect(subject).to eq(['key1', 'key2']) 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/state_changer/base.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'state_changer/container' 4 | require 'forwardable' 5 | 6 | module StateChanger 7 | class Base 8 | def self.state(state_name, &block) 9 | state_container.register(state_name, block) 10 | end 11 | 12 | def self.state?(state_name, data) 13 | state_container[state_name].call(data) 14 | end 15 | 16 | def self.state_container 17 | @state_container ||= StateChanger::Container.new 18 | end 19 | 20 | def self.register_transition(event_name, path, &block) 21 | from_state = path.keys.first 22 | to_state = path.values.first 23 | 24 | transition_container.register(event_name, block, { from: from_state, to: to_state }) 25 | end 26 | 27 | def self.transition_container 28 | @transition_container ||= StateChanger::Container.new 29 | end 30 | 31 | def call(event_name, data) 32 | states = select_states(data) 33 | raise StateChanger::WrongStateError if states.empty? 34 | 35 | # TODO: use condition for getting more than one initial state for data 36 | state = states.first 37 | transition_key = transition_key(event_name, state) 38 | raise StateChanger::WrongTransitionError if transition_key.nil? 39 | 40 | transition_container.get_by_full_key(transition_key).call(data.clone) 41 | end 42 | 43 | def transitions(data) 44 | states = select_states(data) 45 | transition_container.full_keys.select { |i| states.include? i.last[:from].to_s } 46 | end 47 | 48 | extend Forwardable 49 | 50 | def_delegators 'self.class', :state?, :transition_container, :state_container 51 | 52 | private :transition_container, :state_container 53 | 54 | private 55 | 56 | def transition_key(event_name, state) 57 | transition_container.full_keys.detect do |t| 58 | t.first == event_name.to_s && t.last[:from].to_s == state 59 | end 60 | end 61 | 62 | def select_states(data) 63 | state_container.keys.select { |state| state_container[state].call(data) } 64 | end 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /spec/base_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative './helpers/traffic_light' 2 | 3 | RSpec.describe StateChanger::Base do 4 | describe '::state' do 5 | it 'registers new state in specific container' do 6 | expect(TrafficLightStateMachine.state_container.keys).to eq ['red', 'green', 'yellow'] 7 | end 8 | end 9 | 10 | describe '::state?' do 11 | it 'checks data for specific state' do 12 | expect(TrafficLightStateMachine.state?(:red, { light: 'red' })).to eq true 13 | expect(TrafficLightStateMachine.state?(:red, { light: 'green' })).to eq false 14 | end 15 | end 16 | 17 | describe '::state_container' do 18 | it { expect(TrafficLightStateMachine.state_container).to be_a(StateChanger::Container) } 19 | end 20 | 21 | describe '::transition_container' do 22 | it { expect(TrafficLightStateMachine.transition_container).to be_a(StateChanger::Container) } 23 | end 24 | 25 | describe '::register_transition' do 26 | it 'registers new transitions' do 27 | transitions = TrafficLightStateMachine.transition_container.full_keys 28 | 29 | expect(transitions).to eq([ 30 | ['switch', { from: :red, to: :green }], 31 | ['switch', { from: :green, to: :yellow }], 32 | ['switch', { from: :yellow, to: :red }], 33 | ]) 34 | end 35 | end 36 | 37 | describe '#call' do 38 | let(:machine) { TrafficLightStateMachine.new } 39 | 40 | it 'raises WrongStateError if no corresponding state found' do 41 | data = { light: 'invalid' } 42 | 43 | expect { machine.call(:switch, data) }.to raise_error(StateChanger::WrongStateError) 44 | end 45 | 46 | it 'raises WrongTransitionError if no corresponding transition found' do 47 | data = { light: 'red' } 48 | 49 | expect { machine.call(:snitch, data) }.to raise_error(StateChanger::WrongTransitionError) 50 | end 51 | 52 | it 'returns new data mapped to next transition state' do 53 | data = { light: 'red' } 54 | 55 | expect(machine.call(:switch, data)).to eq(light: 'green') 56 | end 57 | 58 | it 'returns not mutated data mapped to next transition state' do 59 | data = { light: 'red' } 60 | 61 | expect(machine.call(:switch, data)).to eq(light: 'green') 62 | expect(machine.call(:switch, data)).to eq(light: 'green') 63 | end 64 | end 65 | 66 | describe '#transitions' do 67 | let(:machine) { TrafficLightStateMachine.new } 68 | 69 | it 'returns list of allowed transitions' do 70 | data = { light: 'red' } 71 | 72 | expect(machine.transitions(data)).to eq([ 73 | ['switch', { from: :red, to: :green }] 74 | ]) 75 | end 76 | 77 | it 'returns empty list if no allowed transitions found for current state' do 78 | data = { light: 'cyan' } 79 | 80 | expect(machine.transitions(data)).to eq([]) 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at antondavydov.o@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [https://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: https://contributor-covenant.org 74 | [version]: https://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # StateChanger 2 | 3 | **Proof of Concept** 4 | 5 | A simple state machine which will change state for each transition and work for any type of data. 6 | 7 | ## Motivation 8 | 9 | You can find a lot of state machine libraries in the ruby ecosystem. All of them are great and I suggest using [aasm](https://github.com/aasm/aasm) and [state_machines](https://github.com/state-machines/state_machines) libraries. 10 | 11 | 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: 12 | 13 | 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. 14 | 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. 15 | 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. 16 | 17 | 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. 18 | 19 | ## Philosophy 20 | 21 | 1. The separation between state machine and data. It's mean that the state machine is not a part of the data object; 22 | 2. Allow to determinate how exactly you want to mutate state for each transition; 23 | 3. Make possible to detect state based on any type of data; 24 | 4. Make it simple and dependency-free. But also, I want to implement extensions behavior for everyone who wants to use something specific; 25 | 26 | ## Installation 27 | 28 | Add this line to your application's Gemfile: 29 | 30 | ```ruby 31 | gem 'state_changer' 32 | ``` 33 | 34 | And then execute: 35 | 36 | $ bundle install 37 | 38 | Or install it yourself as: 39 | 40 | $ gem install state_changer 41 | 42 | ## Usage 43 | 44 | ### Base 45 | #### Base 46 | 47 | For using `StateChanger` library you need to create a container object which will contain state definition and transitions: 48 | 49 | ```ruby 50 | class StateMachine < StateChanger::Base 51 | end 52 | ``` 53 | 54 | All container classes don't contain global state, it's mean that you can create different state machines for one data: 55 | 56 | ```ruby 57 | class OrderStateMachine < StateChanger::Base 58 | end 59 | 60 | class NewOrderStateMachine < StateChanger::Base 61 | end 62 | ``` 63 | #### Defining State 64 | 65 | 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: 66 | 67 | ```ruby 68 | class StateMachine < StateChanger::Base 69 | state(:open) { |hash| hash[:status] == :open } 70 | state(:close) { |object| object.status == :close } 71 | state(:inactive) { |object| object.inactive? } 72 | end 73 | ``` 74 | 75 | You can also use a seporate object with all states for spliting state definition: 76 | 77 | ```ruby 78 | class States < StateChanger::StateMixin 79 | state(:open) { |hash| hash[:status] == :open } 80 | state(:close) { |object| object.status == :close } 81 | state(:inactive) { |object| object.inactive? } 82 | end 83 | 84 | class StateMachine < StateChanger::Base 85 | states States 86 | end 87 | ``` 88 | 89 | #### Transition and events 90 | 91 | 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: 92 | 93 | ```ruby 94 | class StateMachine < StateChanger::Base 95 | # switch - event name for calling transition 96 | # red - initial state for transition 97 | # green - ended state 98 | register_transition(:switch, red: :green) do |data| 99 | data[:light] = 'green' 100 | data 101 | end 102 | 103 | # Also, you can put any objects inside block: 104 | register_transition(:add_item, empty: :active) do |order, item| 105 | # ... 106 | end 107 | 108 | # Or use array as a traget 109 | register_transition(:add_item, [:empty, :active] => :active) do |order, item| 110 | # ... 111 | end 112 | 113 | register_transition(:delete_item, active: [:empty, :active]) do |order, item_id| 114 | # ... 115 | end 116 | 117 | # Also, you can use different targets for one event 118 | register_transition(:switch, red: :green) { |data| ... } 119 | register_transition(:switch, green: :yellow) { |data| ... } 120 | register_transition(:switch, yellow: :red) { |data| ... } 121 | end 122 | ``` 123 | 124 | #### Execution 125 | After defining the list of states and register transitions you can create a new instance of state machine and call specific event: 126 | 127 | ```ruby 128 | state_machine = StateMachine.new 129 | state_machine.call(:event_name, object) 130 | # => this call will return a new object with changed state 131 | ``` 132 | 133 | Also, each `StateChanger` container contain one event `get_state` which returns state of the object: 134 | 135 | ```ruby 136 | state_machine = StateMachine.new 137 | state_machine.call(:get_state, object) 138 | # => paid 139 | ``` 140 | 141 | #### Debugging and audit events 142 | 143 | For debug prespective `StateChanger` container also sends events for each transition call. You can handle this events by adding handler logic: 144 | 145 | ```ruby 146 | class StateMachine < StateChanger::Base 147 | handle_event(:transited) do |transition_name, from, to, old_payload, new_payload| 148 | logger.info('...') 149 | end 150 | end 151 | ``` 152 | 153 | ### Persist state to DB 154 | 155 | It's a common practice to store state to DB in state machine call: 156 | 157 | ```ruby 158 | job.aasm.fire!(:run) # saved 159 | 160 | ``` 161 | `StateChanger` try to use other way and separate persist and transition logic: 162 | 163 | ```ruby 164 | # With AR 165 | paid_order = state_machine.call(:pay, order) 166 | paid_order.save 167 | 168 | # With rom or hanami-model 169 | paid_order = state_machine.call(:pay, order) 170 | repo.update(paid_order.id, paid_order) 171 | ``` 172 | 173 | ### Traffic light example 174 | ```ruby 175 | class TrafficLightStateMachine < StateChanger::Base 176 | state(:red) { |data| data[:light] == 'red' } 177 | state(:green) { |data| data[:light] == 'green' } 178 | state(:yellow) { |data| data[:light] == 'yellow' } 179 | 180 | register_transition(:switch, red: :green) do |data| 181 | data[:light] = 'green' 182 | data 183 | end 184 | 185 | register_transition(:switch, green: :yellow) do |data| 186 | data[:light] = 'yellow' 187 | data 188 | end 189 | register_transition(:switch, yellow: :red) do |data| 190 | data[:light] = 'red' 191 | data 192 | end 193 | end 194 | 195 | state_machine = TrafficLightStateMachine.new 196 | traffic_light = { street: 'B J. Comins, Licensed', light: 'red' } 197 | 198 | new_traffic_light = state_machine.call(:switch, traffic_light) 199 | # => { street: 'B J. Comins, Licensed', light: 'green' } 200 | 201 | state_machine.call(:switch, new_traffic_light) 202 | # => { street: 'B J. Comins, Licensed', light: 'yellow' } 203 | 204 | # `state_machine.call` is pure function, it's mean that it always returns same result for the same data 205 | state_machine.call(:switch, new_traffic_light) 206 | # => { street: 'B J. Comins, Licensed', light: 'yellow' } 207 | 208 | # Also, you can get state based on your data 209 | state_machine.call(:get_state, traffic_light) 210 | # => :red 211 | state_machine.call(:get_state, new_traffic_light) 212 | # => :green 213 | ``` 214 | 215 | ### Order flow example 216 | 217 | ```ruby 218 | class OrderStateMachine 219 | state(:empty) { |order| order.items.empty? && order.payment.nil? } 220 | state(:active) { |order| order.items.any? && order.payment.nil? } 221 | state(:paid) { |order| order.payment } 222 | 223 | register_transition(:add_item, [:empty, :active] => :active) do |order, item_id| 224 | order.items << item 225 | order 226 | end 227 | 228 | register_transition(:remove_item, active: [:empty, :active]) do |order, item_id| 229 | order.remove_item(item_id) 230 | order 231 | end 232 | 233 | register_transition(:pay, active: :paid) do |order| 234 | order.pay 235 | order 236 | end 237 | end 238 | 239 | state_machine = OrderStateMachine.new 240 | 241 | order = Order.new(items: []) 242 | item = { title: 'new book' } 243 | 244 | state_machine.call(:pay, order) 245 | # => returns error object because empty order can't be paid 246 | 247 | active_order = state_machine.call(:add_item, order, item) 248 | # => order with one item in 'active' state 249 | 250 | paid_order = state_machine.call(:pay, active_order) 251 | # => order with paid status 252 | 253 | state_machine.call(:add_item, paid_order, item) 254 | # => returns error again because state invalid for transition 255 | ``` 256 | 257 | ## Contributing 258 | 259 | 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](https://github.com/davydovanton/state_changer/blob/master/CODE_OF_CONDUCT.md). 260 | 261 | 262 | ## License 263 | 264 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 265 | 266 | ## Code of Conduct 267 | 268 | Everyone interacting in the StateChanger project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/davydovanton/state_changer/blob/master/CODE_OF_CONDUCT.md). 269 | --------------------------------------------------------------------------------