├── .ruby-version ├── lib ├── transitions │ ├── version.rb │ ├── presenter.rb │ ├── state.rb │ ├── state_transition.rb │ ├── machine.rb │ └── event.rb ├── active_record │ └── transitions.rb ├── transitions.rb └── active_model │ └── transitions.rb ├── .gitignore ├── bin └── console ├── .codeclimate.yml ├── Gemfile ├── gemfiles ├── rails_3_1.gemfile ├── rails_3_2.gemfile ├── rails_3_0.gemfile └── rails_4_0.gemfile ├── test ├── helper.rb ├── machine │ ├── machine_template.rb │ ├── test_available_states_listing.rb │ ├── test_fire_event_machine.rb │ └── test_machine.rb ├── state │ ├── test_state_predicate_method.rb │ └── test_state.rb ├── event │ ├── test_event_arguments.rb │ ├── test_event_checks.rb │ ├── test_event_being_fired.rb │ └── test_event.rb ├── active_record │ ├── test_custom_select.rb │ ├── test_active_record_scopes.rb │ ├── test_active_record_timestamps.rb │ └── test_active_record.rb └── state_transition │ ├── test_state_transition_event_failed_callback.rb │ ├── test_state_transition.rb │ ├── test_state_transition_success_callback.rb │ ├── test_state_transition_on_transition_callback.rb │ ├── test_state_transition_event_fired_callback.rb │ └── test_state_transition_guard_check.rb ├── Rakefile ├── .travis.yml ├── .rubocop.yml ├── Appraisals ├── LICENSE.txt ├── transitions.gemspec ├── .todo.reek ├── CHANGELOG.md └── README.md /.ruby-version: -------------------------------------------------------------------------------- 1 | 2.2.3 2 | -------------------------------------------------------------------------------- /lib/transitions/version.rb: -------------------------------------------------------------------------------- 1 | module Transitions 2 | VERSION = '1.1.1'.freeze 3 | end 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | pkg 2 | *.gem 3 | .bundle 4 | .idea/ 5 | Gemfile.lock 6 | gemfiles/*.lock 7 | *.swp 8 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'bundler/setup' 4 | require 'transitions' 5 | 6 | require 'pry' 7 | Pry.start 8 | -------------------------------------------------------------------------------- /lib/active_record/transitions.rb: -------------------------------------------------------------------------------- 1 | require 'active_model/transitions' 2 | 3 | module ActiveRecord 4 | Transitions = ActiveModel::Transitions 5 | end 6 | -------------------------------------------------------------------------------- /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | engines: 2 | rubocop: 3 | enabled: true 4 | reek: 5 | enabled: true 6 | ratings: 7 | paths: 8 | - lib/**/* 9 | exclude_paths: 10 | - test/**/* 11 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec 4 | 5 | group :local_development do 6 | gem 'pry' 7 | gem 'sqlite3' 8 | 9 | platforms :mri do 10 | gem 'pry-byebug' 11 | gem 'pry-stack_explorer' 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /gemfiles/rails_3_1.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "~>3.1.12" 6 | gem "activerecord-jdbcsqlite3-adapter", :platforms=>:jruby 7 | gem "sqlite3", :platforms=>:ruby 8 | 9 | gemspec :path=>"../" -------------------------------------------------------------------------------- /gemfiles/rails_3_2.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "~>3.2.14" 6 | gem "activerecord-jdbcsqlite3-adapter", :platforms=>:jruby 7 | gem "sqlite3", :platforms=>:ruby 8 | 9 | gemspec :path=>"../" -------------------------------------------------------------------------------- /gemfiles/rails_3_0.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "~> 3.0.10" 6 | gem "activerecord-jdbcsqlite3-adapter", :platforms=>:jruby 7 | gem "sqlite3", :platforms=>:ruby 8 | 9 | gemspec :path=>"../" -------------------------------------------------------------------------------- /lib/transitions/presenter.rb: -------------------------------------------------------------------------------- 1 | module Transitions 2 | module Presenter 3 | def available_states 4 | get_state_machine.states.map(&:name) 5 | end 6 | 7 | def available_events 8 | get_state_machine.events.keys 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /gemfiles/rails_4_0.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "~>4.0.0" 6 | gem "activerecord-jdbcsqlite3-adapter", "1.3.0.rc1", :platforms=>:jruby 7 | gem "sqlite3", :platforms=>:ruby 8 | 9 | gemspec :path=>"../" -------------------------------------------------------------------------------- /test/helper.rb: -------------------------------------------------------------------------------- 1 | require 'test/unit' 2 | require 'active_record' 3 | 4 | require 'transitions' 5 | require 'active_model/transitions' 6 | 7 | require 'mocha' 8 | require 'random_data' 9 | 10 | def db_defaults! 11 | ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: ':memory:') 12 | ActiveRecord::Migration.verbose = false 13 | end 14 | 15 | def set_up_db(*migrations) 16 | db_defaults! 17 | migrations.each { |klass| klass.send :migrate, :up } 18 | end 19 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler/gem_tasks' 2 | 3 | require 'appraisal' 4 | require 'rake/testtask' 5 | require 'rubocop/rake_task' 6 | require 'reek/rake/task' 7 | 8 | Rake::TestTask.new(:test) do |test| 9 | test.libs = %w(lib test) 10 | test.pattern = 'test/**/test_*.rb' 11 | test.verbose = true 12 | end 13 | 14 | RuboCop::RakeTask.new do |task| 15 | task.options << '--display-cop-names' 16 | task.patterns = ['lib/**/*.rb'] 17 | end 18 | 19 | Reek::Rake::Task.new 20 | 21 | task default: [:test, :reek, :rubocop] 22 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | cache: bundler 3 | language: ruby 4 | bundler_args: --without local_development 5 | script: 6 | - bundle exec rake 7 | rvm: 8 | - 2.1 9 | - 2.2 10 | - 2.3.0 11 | - rbx-2 12 | - jruby 13 | matrix: 14 | allow_failures: 15 | - rvm: rbx-2 16 | - rvm: jruby 17 | fast_finish: true 18 | notifications: 19 | email: 20 | - timo.roessner@googlemail.com 21 | gemfile: 22 | - gemfiles/rails_3_0.gemfile 23 | - gemfiles/rails_3_1.gemfile 24 | - gemfiles/rails_3_2.gemfile 25 | - gemfiles/rails_4_0.gemfile 26 | -------------------------------------------------------------------------------- /test/machine/machine_template.rb: -------------------------------------------------------------------------------- 1 | class MachineTestSubject 2 | include Transitions 3 | 4 | state_machine initial: :open do 5 | state :open 6 | state :closed 7 | 8 | event :shutdown do 9 | transitions from: :open, to: :closed 10 | end 11 | 12 | event :timeout do 13 | transitions from: :open, to: :closed 14 | end 15 | 16 | event :restart do 17 | transitions from: :closed, to: :open, guard: :restart_allowed? 18 | end 19 | end 20 | 21 | def restart_allowed?(allowed = true) 22 | allowed 23 | end 24 | 25 | def event_failed(*) 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /test/machine/test_available_states_listing.rb: -------------------------------------------------------------------------------- 1 | require 'helper' 2 | 3 | class Bender 4 | include Transitions 5 | 6 | state_machine do 7 | state :drinking 8 | state :smoking 9 | state :gambling 10 | 11 | event :cough do 12 | transitions from: :smoking, to: :gambling 13 | end 14 | end 15 | end 16 | 17 | class TestAvailableStatesListing < Test::Unit::TestCase 18 | test 'available_states should return the states for the state machine' do 19 | assert_equal [:drinking, :smoking, :gambling], Bender.available_states 20 | end 21 | test 'available_events should return the events for the state machine' do 22 | assert_equal [:cough], Bender.available_events 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | Metrics/LineLength: 2 | Max: 120 3 | Documentation: 4 | Enabled: false 5 | Lint/AssignmentInCondition: 6 | Enabled: false 7 | # For whatever reasons disabling the cops below via source comment does **not** work. 8 | # This happens only for event.rb, disabling those cops in other files via comment works well. 9 | Metrics/MethodLength: 10 | Exclude: 11 | - 'lib/transitions/event.rb' 12 | Metrics/AbcSize: 13 | Exclude: 14 | - 'lib/transitions/event.rb' 15 | Metrics/CyclomaticComplexity: 16 | Exclude: 17 | - 'lib/transitions/event.rb' 18 | Style/ConditionalAssignment: 19 | Exclude: 20 | - 'lib/transitions/event.rb' 21 | - 'lib/transitions/state.rb' 22 | Style/FrozenStringLiteralComment: 23 | Enabled: false 24 | -------------------------------------------------------------------------------- /Appraisals: -------------------------------------------------------------------------------- 1 | appraise "rails_3_0" do 2 | gem "activerecord", "~> 3.0.10" 3 | gem "activerecord-jdbcsqlite3-adapter", :platforms=>:jruby 4 | gem 'sqlite3', :platforms => :ruby 5 | end 6 | 7 | appraise "rails_3_1" do 8 | gem "activerecord", "~>3.1.12" 9 | gem "activerecord-jdbcsqlite3-adapter", :platforms=>:jruby 10 | gem 'sqlite3', :platforms => :ruby 11 | end 12 | 13 | appraise "rails_3_2" do 14 | gem "activerecord", "~>3.2.14" 15 | gem "activerecord-jdbcsqlite3-adapter", :platforms=>:jruby 16 | gem 'sqlite3', :platforms => :ruby 17 | end 18 | 19 | appraise "rails_4_0" do 20 | gem "activerecord", "~>4.0.0" 21 | gem "activerecord-jdbcsqlite3-adapter", '1.3.0.rc1', :platforms=>:jruby 22 | gem 'sqlite3', :platforms => :ruby 23 | end 24 | -------------------------------------------------------------------------------- /test/state/test_state_predicate_method.rb: -------------------------------------------------------------------------------- 1 | require 'helper' 2 | 3 | class Bus 4 | include Transitions 5 | 6 | state_machine do 7 | state :parking 8 | end 9 | end 10 | 11 | class TestStatePredicateMethod < Test::Unit::TestCase 12 | def setup 13 | @bus = Bus.new 14 | end 15 | 16 | test 'should generate predicate methods for states' do 17 | assert_true @bus.respond_to?(:parking?) 18 | assert_true @bus.send(:parking?) 19 | end 20 | 21 | test 'should raise `InvalidMethodOverride` if we try to overwrite existing methods' do 22 | assert_raise(Transitions::InvalidMethodOverride) do 23 | Class.new do 24 | include Transitions 25 | 26 | state_machine do 27 | state :frozen 28 | end 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /test/event/test_event_arguments.rb: -------------------------------------------------------------------------------- 1 | require 'helper' 2 | 3 | class ArgumentsTestSubject 4 | include Transitions 5 | attr_accessor :date 6 | 7 | state_machine do 8 | state :initial 9 | state :opened 10 | 11 | event :open do 12 | transitions from: :initial, to: :opened, on_transition: :update_date 13 | end 14 | end 15 | 16 | def update_date(date = Date.today) 17 | self.date = date 18 | end 19 | end 20 | 21 | class StateMachineMachineTest < Test::Unit::TestCase 22 | test 'pass arguments to transition method' do 23 | subject = ArgumentsTestSubject.new 24 | assert_equal :initial, subject.current_state 25 | subject.open!(Date.yesterday) 26 | assert_equal :opened, subject.current_state 27 | assert_equal Date.yesterday, subject.date 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /test/event/test_event_checks.rb: -------------------------------------------------------------------------------- 1 | require 'helper' 2 | 3 | class ChecksTestSubject 4 | include Transitions 5 | 6 | state_machine initial: :initial do 7 | state :initial 8 | state :opened 9 | state :closed 10 | 11 | event :open do 12 | transitions from: :initial, to: :opened 13 | end 14 | 15 | event :close do 16 | transitions from: :opened, to: :closed 17 | end 18 | end 19 | end 20 | 21 | class StateMachineChecksTest < Test::Unit::TestCase 22 | test 'checks if a given transition is possible' do 23 | subject = ChecksTestSubject.new 24 | assert_equal :initial, subject.current_state 25 | assert_equal true, subject.can_open? 26 | assert_equal false, subject.can_close? 27 | 28 | subject.open 29 | 30 | assert_equal false, subject.can_open? 31 | assert_equal true, subject.can_close? 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /test/active_record/test_custom_select.rb: -------------------------------------------------------------------------------- 1 | require 'helper' 2 | 3 | # Regression test for https://github.com/troessner/transitions/issues/95 4 | class CreateSwitches < ActiveRecord::Migration 5 | def self.up 6 | create_table(:switches, force: true) do |t| 7 | t.string :state 8 | end 9 | end 10 | end 11 | 12 | class Switch < ActiveRecord::Base 13 | include ActiveModel::Transitions 14 | 15 | state_machine do 16 | state :off 17 | state :on 18 | end 19 | end 20 | 21 | class TestCustomSelect < Test::Unit::TestCase 22 | def setup 23 | set_up_db CreateSwitches 24 | Switch.create! 25 | end 26 | 27 | test 'should not trigger an exception when we use a custom select query which excludes the name of our state attribute' do 28 | result = Switch.select(:id) 29 | assert_nothing_raised NoMethodError do 30 | result.inspect 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /test/event/test_event_being_fired.rb: -------------------------------------------------------------------------------- 1 | require 'helper' 2 | 3 | class TestEventBeingFired < Test::Unit::TestCase 4 | test 'should raise an Transitions::InvalidTransition error if the transitions are empty' do 5 | event = Transitions::Event.new(nil, :event_that_is_fired) 6 | class AnotherDummy; end 7 | obj = AnotherDummy.new 8 | obj.stubs(:current_state).returns(:running) 9 | 10 | exception = assert_raise Transitions::InvalidTransition do 11 | event.fire(obj) 12 | end 13 | assert_match /Can't fire event `event_that_is_fired` in current state `running` for `TestEventBeingFired::AnotherDummy`/, exception.message 14 | end 15 | 16 | test 'should return the state of the first matching transition it finds' do 17 | event = Transitions::Event.new(nil, :event) do 18 | transitions to: :closed, from: [:open, :received] 19 | end 20 | 21 | obj = stub 22 | obj.stubs(:current_state).returns(:open) 23 | 24 | assert_equal :closed, event.fire(obj) 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /test/state_transition/test_state_transition_event_failed_callback.rb: -------------------------------------------------------------------------------- 1 | require 'helper' 2 | 3 | class Car 4 | include Transitions 5 | 6 | state_machine do 7 | state :parked 8 | state :driving 9 | state :switched_off 10 | 11 | event :start_driving do 12 | transitions from: :parked, to: :driving 13 | end 14 | 15 | event :switch_off_engine do 16 | transitions from: :parked, to: :switched_off 17 | end 18 | end 19 | end 20 | 21 | class TestStateTransitionEventFailedCallback < Test::Unit::TestCase 22 | def setup 23 | @car = Car.new 24 | end 25 | 26 | test "should execute the event_failed_callback and don't raise error if callback is defined" do 27 | @car.start_driving 28 | @car.expects(:event_failed).with(:switch_off_engine) 29 | @car.switch_off_engine 30 | end 31 | 32 | test "should just re-raise any error on transition if the event_failed_callback isn't defined" do 33 | @car.start_driving 34 | assert_raise(Transitions::InvalidTransition) { @car.switch_off_engine } 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /test/machine/test_fire_event_machine.rb: -------------------------------------------------------------------------------- 1 | require 'helper' 2 | require_relative './machine_template' 3 | 4 | class TestFireEventMachine < Test::Unit::TestCase 5 | def setup 6 | @record = MachineTestSubject.new 7 | @machine = MachineTestSubject.get_state_machine 8 | @event = @machine.events_for(@record.current_state).first 9 | assert_not_nil @event 10 | end 11 | 12 | test 'fire_event returns true if state transition was successful' do 13 | @machine.stubs(:transition_to_new_state).returns(:closed) 14 | 15 | assert_equal true, @machine.fire_event(@event, @record, false) 16 | end 17 | 18 | test 'fire_event returns false if state transition was unsuccessful' do 19 | @machine.stubs(:transition_to_new_state).returns(false) 20 | 21 | assert_equal false, @machine.fire_event(@event, @record, false) 22 | end 23 | 24 | test 'fire_event returns false if state transition raises' do 25 | @machine.stubs(:transition_to_new_state).raises(StandardError) 26 | 27 | assert_equal false, @machine.fire_event(@event, @record, false) 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Timo Rößner 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 | -------------------------------------------------------------------------------- /test/state_transition/test_state_transition.rb: -------------------------------------------------------------------------------- 1 | require 'helper' 2 | 3 | class TestStateTransition < Test::Unit::TestCase 4 | test 'should set from, to, and opts attr readers' do 5 | opts = { from: 'foo', to: 'bar', guard: 'g' } 6 | st = Transitions::StateTransition.new(opts) 7 | 8 | assert_equal opts[:from], st.from 9 | assert_equal opts[:to], st.to 10 | end 11 | 12 | test 'should pass equality check if from and to are the same' do 13 | opts = { from: 'foo', to: 'bar', guard: 'g' } 14 | st = Transitions::StateTransition.new(opts) 15 | 16 | obj = stub 17 | obj.stubs(:from).returns(opts[:from]) 18 | obj.stubs(:to).returns(opts[:to]) 19 | 20 | assert_equal st, obj 21 | end 22 | 23 | test 'should fail equality check if from are not the same' do 24 | opts = { from: 'foo', to: 'bar', guard: 'g' } 25 | st = Transitions::StateTransition.new(opts) 26 | 27 | obj = stub 28 | obj.stubs(:from).returns('blah') 29 | obj.stubs(:to).returns(opts[:to]) 30 | 31 | assert_not_equal st, obj 32 | end 33 | 34 | test 'should fail equality check if to are not the same' do 35 | opts = { from: 'foo', to: 'bar', guard: 'g' } 36 | st = Transitions::StateTransition.new(opts) 37 | 38 | obj = stub 39 | obj.stubs(:from).returns(opts[:from]) 40 | obj.stubs(:to).returns('blah') 41 | 42 | assert_not_equal st, obj 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /test/state_transition/test_state_transition_success_callback.rb: -------------------------------------------------------------------------------- 1 | require 'helper' 2 | 3 | class DrivingInstructor 4 | def self.applause! 5 | end 6 | end 7 | 8 | class DrivingSchoolCar 9 | include Transitions 10 | 11 | state_machine do 12 | state :parked 13 | state :running 14 | state :driving 15 | state :switched_off 16 | 17 | event :start_driving, success: ->(_car) { DrivingInstructor.applause! } do 18 | transitions from: :parked, to: :driving, on_transition: [:start_engine, :loosen_handbrake, :push_gas_pedal] 19 | end 20 | 21 | event :switch_off_engine do 22 | transitions from: :parked, to: :switched_off 23 | end 24 | end 25 | 26 | %w(start_engine loosen_handbrake push_gas_pedal).each do |m| 27 | define_method(m) {} 28 | end 29 | end 30 | 31 | class TestStateTransitionSuccessCallback < Test::Unit::TestCase 32 | def setup 33 | @car = DrivingSchoolCar.new 34 | end 35 | 36 | test 'should execute the success callback after successfull event execution' do 37 | DrivingInstructor.expects(:applause!) 38 | 39 | @car.start_driving! 40 | end 41 | 42 | test 'should not execute the success callback after event execution failed' do 43 | DrivingInstructor.expects(:applause!).never 44 | 45 | @car.stubs(:event_failed) 46 | @car.expects(:loosen_handbrake).raises('Drive with handbrake fail!') 47 | @car.start_driving! 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /transitions.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'transitions/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = 'transitions' 8 | spec.version = Transitions::VERSION 9 | spec.authors = ['Timo Rößner'] 10 | spec.email = ['timo.roessner@googlemail.com'] 11 | 12 | spec.summary = 'State machine extracted from ActiveModel' 13 | spec.description = 'Lightweight state machine extracted from ActiveModel' 14 | spec.homepage = 'http://github.com/troessner/transitions' 15 | spec.license = 'MIT' 16 | 17 | spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 18 | spec.require_paths = ['lib'] 19 | 20 | spec.add_development_dependency 'bundler', '~> 1.0' 21 | spec.add_development_dependency 'rake' 22 | spec.add_development_dependency 'test-unit', '~> 2.5' 23 | spec.add_development_dependency 'mocha', '~> 0.11.0' # With mocha 0.12 we get: undefined method `run' for # (NoMethodError) 24 | spec.add_development_dependency 'random_data' 25 | spec.add_development_dependency 'appraisal' 26 | spec.add_development_dependency 'activerecord', ['>= 3.0', '<= 4.0'] 27 | spec.add_development_dependency 'rubocop', '~> 0.36.0' 28 | spec.add_development_dependency 'reek' 29 | end 30 | -------------------------------------------------------------------------------- /test/state_transition/test_state_transition_on_transition_callback.rb: -------------------------------------------------------------------------------- 1 | require 'helper' 2 | 3 | class Truck 4 | include Transitions 5 | attr_reader :test_recorder 6 | 7 | def initialize 8 | @test_recorder = [] 9 | end 10 | 11 | state_machine do 12 | state :parked 13 | state :running 14 | state :driving 15 | 16 | event :turn_key do 17 | transitions from: :parked, to: :running, on_transition: :start_engine 18 | end 19 | 20 | event :start_driving do 21 | transitions from: :parked, to: :driving, on_transition: [:start_engine, :loosen_handbrake, :push_gas_pedal] 22 | end 23 | end 24 | 25 | %w(start_engine loosen_handbrake push_gas_pedal).each do |m| 26 | define_method(m) { @test_recorder << m } 27 | end 28 | end 29 | 30 | class TestStateTransitionCallbacks < Test::Unit::TestCase 31 | test "should execute callback defined via 'on_transition'" do 32 | truck = Truck.new 33 | truck.expects(:start_engine) 34 | truck.turn_key! 35 | end 36 | 37 | test "should execute multiple callbacks defined via 'on_transition' in the same order they were defined" do 38 | # This test requires some explanation: We started out with something like this: 39 | # truck.expects(:start_engine).in_sequence(on_transition_sequence) 40 | # Which, after a while (don't ask me why) caused some weird problems and seemed to fail randomly. 41 | # Hence the workaround below. 42 | 43 | truck = Truck.new 44 | 45 | truck.start_driving! 46 | assert_equal truck.test_recorder, [:start_engine, :loosen_handbrake, :push_gas_pedal].map(&:to_s) 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /test/state_transition/test_state_transition_event_fired_callback.rb: -------------------------------------------------------------------------------- 1 | require 'helper' 2 | 3 | class Car 4 | include Transitions 5 | 6 | state_machine do 7 | state :parked 8 | state :driving 9 | 10 | event :start_driving do 11 | transitions from: :parked, to: :driving 12 | end 13 | end 14 | end 15 | 16 | class TestStateTransitionEventFiredCallback < Test::Unit::TestCase 17 | def setup 18 | @car = Car.new 19 | end 20 | 21 | test 'should execute the event_fired callback after successfull event execution if it callback is defined' do 22 | @car.stubs(:event_fired) 23 | @car.expects(:event_fired).with(:parked, :driving, :start_driving).once 24 | 25 | @car.start_driving! 26 | end 27 | 28 | test 'should not execute the event_fired callback after successfull event execution if it callback is not defined' do 29 | pend 'Test fails right now although functionality is working as expected' 30 | # This test fails right now even though it works as expected in the console. 31 | # The reason for this is, that mocha's `expects` does a little bit more than just set up an expectation, 32 | # it actually defines this method if it doesn't exist or at least it overwrites respond_to? 33 | # @car.respond_to?(:event_fired) 34 | # returns false before the `expects` call, but true after. 35 | # Hence, this test fails. 36 | # Something like 37 | # @car.instance_eval { undef :event_fired } 38 | # doesn't work either, probably because expects just overwrites respond_to? 39 | # but does not define the method 40 | # How to fix? 41 | @car.expects(:event_fired).never 42 | @car.start_driving! 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/transitions/state.rb: -------------------------------------------------------------------------------- 1 | module Transitions 2 | class State 3 | attr_reader :name, :options 4 | 5 | def initialize(name, options = {}) 6 | @name = name 7 | if machine = options.delete(:machine) 8 | define_state_query_method(machine) 9 | end 10 | update(options) 11 | end 12 | 13 | def ==(other) 14 | if other.is_a? Symbol 15 | name == other 16 | else 17 | name == other.name 18 | end 19 | end 20 | 21 | def call_action(action, record) 22 | action = @options[action] 23 | case action 24 | when Symbol, String 25 | record.send(action) 26 | when Proc 27 | action.call(record) 28 | end 29 | end 30 | 31 | def display_name 32 | @display_name ||= name.to_s.tr('_', ' ').capitalize 33 | end 34 | 35 | def for_select 36 | [display_name, name.to_s] 37 | end 38 | 39 | def update(options = {}) 40 | @display_name = options.delete(:display) if options.key?(:display) 41 | @options = options 42 | self 43 | end 44 | 45 | private 46 | 47 | def define_state_query_method(machine) 48 | method_name = "#{@name}?" 49 | state_name = @name # Instance vars are out of scope when calling define_method below, so we use local variables. 50 | if machine.klass.method_defined?(method_name.to_sym) 51 | fail InvalidMethodOverride, 52 | "Transitions: Can not define method `#{method_name}` because it is already"\ 53 | 'defined - either rename the existing method or the state.' 54 | end 55 | machine.klass.send :define_method, method_name do 56 | current_state.to_s == state_name.to_s 57 | end 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /test/active_record/test_active_record_scopes.rb: -------------------------------------------------------------------------------- 1 | require 'helper' 2 | 3 | class CreateBunnies < ActiveRecord::Migration 4 | def self.up 5 | create_table(:bunnies, force: true) do |t| 6 | t.string :status # Explicitly use another state column to ensure that this whole enchilada is working with other state column names than the default ones. 7 | end 8 | end 9 | end 10 | 11 | class CreatePuppies < ActiveRecord::Migration 12 | def self.up 13 | create_table(:puppies, force: true) do |t| 14 | t.string :state 15 | end 16 | end 17 | end 18 | 19 | class Bunny < ActiveRecord::Base 20 | include ActiveModel::Transitions 21 | 22 | state_machine attribute_name: :status, auto_scopes: true do 23 | state :hobbling 24 | end 25 | end 26 | 27 | class Puppy < ActiveRecord::Base 28 | include ActiveModel::Transitions 29 | 30 | state_machine do 31 | state :barking 32 | end 33 | end 34 | 35 | class TestScopes < Test::Unit::TestCase 36 | def setup 37 | set_up_db CreateBunnies, CreatePuppies 38 | @bunny = Bunny.create! 39 | end 40 | 41 | test 'scopes exist' do 42 | assert_respond_to Bunny, :hobbling 43 | end 44 | 45 | test 'scope returns correct object' do 46 | assert_equal Bunny.hobbling.first, @bunny 47 | end 48 | 49 | test 'scopes are only generated if we explicitly say so' do 50 | assert_not_respond_to Puppy, :barking 51 | end 52 | 53 | test 'scope generation raises an exception if we try to overwrite an existing method' do 54 | assert_raise(Transitions::InvalidMethodOverride) do 55 | Class.new(ActiveRecord::Base) do 56 | include ActiveModel::Transitions 57 | 58 | state_machine auto_scopes: true do 59 | state :new 60 | end 61 | end 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /test/machine/test_machine.rb: -------------------------------------------------------------------------------- 1 | require 'helper' 2 | 3 | class MachineTestSubject 4 | include Transitions 5 | 6 | state_machine initial: :closed do 7 | state :open 8 | state :closed 9 | 10 | event :shutdown do 11 | transitions from: :open, to: :closed 12 | end 13 | 14 | event :timeout do 15 | transitions from: :open, to: :closed 16 | end 17 | 18 | event :restart do 19 | transitions from: :closed, to: :open, guard: :restart_allowed? 20 | end 21 | end 22 | 23 | def restart_allowed?(allowed = true) 24 | allowed 25 | end 26 | end 27 | 28 | class TransitionsMachineTest < Test::Unit::TestCase 29 | test 'sets #initial_state from :initial option' do 30 | assert_equal :closed, MachineTestSubject.get_state_machine.initial_state 31 | end 32 | 33 | test '`get_state_machine` returns Transitions::Machine' do 34 | assert_kind_of Transitions::Machine, MachineTestSubject.get_state_machine 35 | end 36 | 37 | test 'finds events for given state' do 38 | events = MachineTestSubject.get_state_machine.events_for(:open) 39 | assert events.include?(:shutdown) 40 | assert events.include?(:timeout) 41 | end 42 | 43 | test 'knows all available transitions for current state' do 44 | machine = MachineTestSubject.new 45 | assert_equal [:restart], machine.available_transitions 46 | machine.restart 47 | assert_equal [:shutdown, :timeout], machine.available_transitions 48 | end 49 | 50 | test 'knows that it can use a transition when it is available' do 51 | machine = MachineTestSubject.new 52 | machine.restart 53 | assert machine.can_transition?(:shutdown) 54 | end 55 | 56 | test "knows that it can't use a transition when it is unavailable" do 57 | machine = MachineTestSubject.new 58 | assert machine.cant_transition?(:shutdown) 59 | end 60 | 61 | test "knows that it can't transition to a state denied by a guard" do 62 | machine = MachineTestSubject.new 63 | assert machine.can_execute_restart? true 64 | refute machine.can_execute_restart? false 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /test/state/test_state.rb: -------------------------------------------------------------------------------- 1 | require 'helper' 2 | 3 | class TestState < Test::Unit::TestCase 4 | def setup 5 | machine = Class.new do 6 | include Transitions 7 | state_machine do 8 | end 9 | end.get_state_machine 10 | state_name = :astate 11 | @options = { machine: machine, custom_key: :my_key } 12 | @state = Transitions::State.new(state_name, @options) 13 | end 14 | 15 | def new_state_name 16 | Random.alphanumeric(16) 17 | end 18 | 19 | test 'sets the name' do 20 | assert_equal :astate, @state.name 21 | end 22 | 23 | test 'sets the display_name from name' do 24 | assert_equal 'Astate', @state.display_name 25 | end 26 | 27 | test 'sets the display_name from options' do 28 | assert_equal 'A State', Transitions::State.new(new_state_name, @options.merge(display: 'A State')).display_name 29 | end 30 | 31 | test 'sets the options and expose them as options' do 32 | @options.delete(:machine) 33 | state = Transitions::State.new new_state_name, @options 34 | assert_equal @options, state.options 35 | end 36 | 37 | test 'equals a symbol of the same name' do 38 | assert_equal @state, :astate 39 | end 40 | 41 | test 'equals a State of the same name' do 42 | assert_equal @state, @state 43 | end 44 | 45 | test 'should send a message to the record for an action if the action is present as a symbol' do 46 | state = Transitions::State.new new_state_name, @options.merge(entering: :foo) 47 | 48 | record = stub 49 | record.expects(:foo) 50 | 51 | state.call_action(:entering, record) 52 | end 53 | 54 | test 'should send a message to the record for an action if the action is present as a string' do 55 | state = Transitions::State.new new_state_name, @options.merge(entering: 'foo') 56 | 57 | record = stub 58 | record.expects(:foo) 59 | 60 | state.call_action(:entering, record) 61 | end 62 | 63 | test 'should call a proc, passing in the record for an action if the action is present' do 64 | state = Transitions::State.new new_state_name, @options.merge(entering: proc(&:foobar)) 65 | 66 | record = stub 67 | record.expects(:foobar) 68 | 69 | state.call_action(:entering, record) 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /test/state_transition/test_state_transition_guard_check.rb: -------------------------------------------------------------------------------- 1 | require 'helper' 2 | 3 | class TestStateTransitionGuardCheck < Test::Unit::TestCase 4 | args = [:foo, 'bar'] 5 | 6 | test 'should return true of there is no guard' do 7 | opts = { from: 'foo', to: 'bar' } 8 | st = Transitions::StateTransition.new(opts) 9 | 10 | assert st.executable?(nil, *args) 11 | end 12 | 13 | test 'should call the method on the object if guard is a symbol' do 14 | opts = { from: 'foo', to: 'bar', guard: :test_guard } 15 | st = Transitions::StateTransition.new(opts) 16 | 17 | obj = stub 18 | obj.expects(:test_guard).with(*args) 19 | 20 | st.executable?(obj, *args) 21 | end 22 | 23 | test 'should call the method on the object if guard is a string' do 24 | opts = { from: 'foo', to: 'bar', guard: 'test_guard' } 25 | st = Transitions::StateTransition.new(opts) 26 | 27 | obj = stub 28 | obj.expects(:test_guard).with(*args) 29 | 30 | st.executable?(obj, *args) 31 | end 32 | 33 | test 'should call the proc passing the object if the guard is a proc' do 34 | opts = { from: 'foo', to: 'bar', guard: proc { |o, *args| o.test_guard(*args) } } 35 | st = Transitions::StateTransition.new(opts) 36 | 37 | obj = stub 38 | obj.expects(:test_guard).with(*args) 39 | 40 | st.executable?(obj, *args) 41 | end 42 | 43 | test 'should call the callable passing the object if the guard responds to #call' do 44 | callable = Object.new 45 | callable.define_singleton_method(:call) { |obj, *args| obj.test_guard(*args) } 46 | 47 | opts = { from: 'foo', to: 'bar', guard: callable } 48 | st = Transitions::StateTransition.new(opts) 49 | 50 | obj = stub 51 | obj.expects(:test_guard).with(*args) 52 | 53 | st.executable?(obj, *args) 54 | end 55 | 56 | test 'should call the method on the object if guard is a symbol' do 57 | opts = { from: 'foo', to: 'bar', guard: [:test_guard, :test_another_guard] } 58 | st = Transitions::StateTransition.new(opts) 59 | 60 | obj = stub 61 | obj.expects(:test_guard).with(*args).returns(true) 62 | obj.expects(:test_another_guard).with(*args).returns(true) 63 | 64 | assert st.executable?(obj, *args) 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /lib/transitions/state_transition.rb: -------------------------------------------------------------------------------- 1 | module Transitions 2 | class StateTransition 3 | attr_reader :from, :to, :guard, :on_transition 4 | # TODO: `from` and `to` should be private as well 5 | private :guard, :on_transition 6 | 7 | def initialize(opts) 8 | @from = opts[:from] 9 | @to = opts[:to] 10 | @guard = opts[:guard] 11 | @on_transition = opts[:on_transition] 12 | @options = opts 13 | end 14 | 15 | # 16 | # @param obj [Any] - the subject 17 | # @param args [Array] - any arguments passed into the transition method 18 | # E.g. something like 19 | # car.drive!(:fast, :now) 20 | # with `car` being the subject and `drive` the transition method would result 21 | # in `args` looking like this: 22 | # [:fast, :now] 23 | # 24 | # @return [Bool] 25 | # 26 | def executable?(obj, *args) 27 | [@guard].flatten.all? { |g| perform_guard(obj, g, *args) } 28 | end 29 | 30 | # 31 | # @param obj [Any] - the subject 32 | # @param args [Array] - any arguments passed into the transition method 33 | # E.g. something like 34 | # car.drive!(:fast, :now) 35 | # with `car` being the subject and `drive` the transition method would result 36 | # in `args` looking like this: 37 | # [:fast, :now] 38 | # 39 | # @return [void] 40 | # 41 | # rubocop:disable Metrics/MethodLength 42 | # 43 | def execute(obj, *args) 44 | case @on_transition 45 | when Symbol, String 46 | obj.send(@on_transition, *args) 47 | when Proc 48 | @on_transition.call(obj, *args) 49 | when Array 50 | @on_transition.each do |callback| 51 | # Yes, we're passing always the same parameters for each callback in here. 52 | # We should probably drop args altogether in case we get an array. 53 | obj.send(callback, *args) 54 | end 55 | else 56 | # TODO: We probably should check for this in the constructor and not that late. 57 | fail ArgumentError, 58 | "You can only pass a Symbol, a String, a Proc or an Array to 'on_transition'"\ 59 | " - got #{@on_transition.class}." unless @on_transition.nil? 60 | end 61 | end 62 | 63 | def ==(other) 64 | @from == other.from && @to == other.to 65 | end 66 | 67 | def from?(value) 68 | @from == value 69 | end 70 | 71 | private 72 | 73 | def perform_guard(obj, guard, *args) 74 | if guard.respond_to?(:call) 75 | guard.call(obj, *args) 76 | elsif guard.is_a?(Symbol) || guard.is_a?(String) 77 | obj.send(guard, *args) 78 | else 79 | true 80 | end 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /lib/transitions.rb: -------------------------------------------------------------------------------- 1 | require 'transitions/event' 2 | require 'transitions/machine' 3 | require 'transitions/presenter' 4 | require 'transitions/state' 5 | require 'transitions/state_transition' 6 | require 'transitions/version' 7 | 8 | module Transitions 9 | class InvalidTransition < StandardError; end 10 | class InvalidMethodOverride < StandardError; end 11 | include Presenter 12 | 13 | module ClassMethods 14 | include Presenter 15 | 16 | def inherited(klass) 17 | super # Make sure we call other callbacks possibly defined upstream the ancestor chain. 18 | klass.state_machine = state_machine 19 | end 20 | 21 | # The only reason we need this method is for the inherited callback. 22 | def state_machine=(value) 23 | @state_machine = value.dup 24 | end 25 | 26 | def state_machine(options = {}, &block) 27 | @state_machine ||= Machine.new self 28 | block ? @state_machine.update(options, &block) : @state_machine 29 | end 30 | 31 | # rubocop:disable Style/AccessorMethodName 32 | def get_state_machine 33 | @state_machine 34 | end 35 | end 36 | 37 | def self.included(base) 38 | base.extend(ClassMethods) 39 | end 40 | 41 | def get_state_machine 42 | self.class.get_state_machine 43 | end 44 | 45 | def update_current_state(new_state, persist = false) 46 | ivar = get_state_machine.current_state_variable 47 | 48 | if Transitions.active_model_descendant?(self.class) 49 | write_state(new_state) if persist 50 | # TODO: This seems like a duplicate, `write_new` already calls `write_state_without_persistence`. 51 | write_state_without_persistence(new_state) 52 | end 53 | 54 | instance_variable_set(ivar, new_state) 55 | end 56 | 57 | def available_transitions 58 | get_state_machine.events_for(current_state) 59 | end 60 | 61 | def can_transition?(*events) 62 | events.all? do |event| 63 | self.class.get_state_machine.events_for(current_state).include?(event.to_sym) 64 | end 65 | end 66 | 67 | def cant_transition?(*events) 68 | !can_transition?(*events) 69 | end 70 | 71 | def current_state 72 | sm = get_state_machine 73 | ivar = sm.current_state_variable 74 | 75 | value = instance_variable_get(ivar) 76 | return value if value 77 | 78 | if Transitions.active_model_descendant?(self.class) 79 | value = instance_variable_set(ivar, read_state) 80 | end 81 | 82 | !(value.nil? || value.to_s.empty?) ? value : sm.initial_state 83 | end 84 | 85 | def self.active_model_descendant?(klazz) 86 | # Checking directly for "ActiveModel" wouldn't work so we use some arbitrary module close to it. 87 | defined?(ActiveModel) && klazz.included_modules.include?(ActiveModel::Dirty) 88 | end 89 | end 90 | -------------------------------------------------------------------------------- /test/event/test_event.rb: -------------------------------------------------------------------------------- 1 | require 'helper' 2 | 3 | class TestEvent < Test::Unit::TestCase 4 | def setup 5 | @state_name = :close_order 6 | @success_as_symbol = :success_callback 7 | @success_as_lambda = ->(record) { record.success_callback } 8 | @success_as_array = [@success_as_symbol, @success_as_lambda] 9 | end 10 | 11 | def event_with_symbol_success_callback 12 | @event = Transitions::Event.new(nil, @state_name, success: @success_as_symbol) do 13 | transitions to: :closed, from: [:open, :received] 14 | end 15 | end 16 | alias_method :new_event, :event_with_symbol_success_callback 17 | 18 | def event_with_lambda_success_callback 19 | @event = Transitions::Event.new(nil, @state_name, success: @success_as_lambda) do 20 | transitions to: :closed, from: [:open, :received] 21 | end 22 | end 23 | 24 | def event_with_array_success_callback 25 | @event = Transitions::Event.new(nil, @state_name, success: @success_as_array) do 26 | transitions to: :closed, from: [:open, :received] 27 | end 28 | end 29 | 30 | test 'should set the name' do 31 | assert_equal @state_name, new_event.name 32 | end 33 | 34 | test 'should set the success callback with a symbol and return a block' do 35 | assert_respond_to event_with_symbol_success_callback.success, :call 36 | end 37 | 38 | test 'should build a block which calls the given success_callback symbol on the passed record instance' do 39 | record = mock('SomeRecordToGetCalled') 40 | record.expects(:success_callback) 41 | 42 | event_with_symbol_success_callback.success.call(record) 43 | end 44 | 45 | test 'should set the success callback with a lambda' do 46 | assert_respond_to event_with_lambda_success_callback.success, :call 47 | end 48 | 49 | test 'should build a block which calls the given success_callback lambda on the passed record instance' do 50 | record = mock('SomeRecordToGetCalled') 51 | record.expects(:success_callback) 52 | 53 | event_with_lambda_success_callback.success.call(record) 54 | end 55 | 56 | test 'should set the success callback with an array' do 57 | assert_respond_to event_with_array_success_callback.success, :call 58 | end 59 | 60 | test 'should build a block which calls the given success_callback array on the passed record instance for each callback' do 61 | record = mock('SomeRecordToGetCalled') 62 | record.expects(:success_callback).twice 63 | 64 | event_with_array_success_callback.success.call(record) 65 | end 66 | 67 | test 'should create StateTransitions' do 68 | Transitions::StateTransition.expects(:new).with(to: :closed, from: :open) 69 | Transitions::StateTransition.expects(:new).with(to: :closed, from: :received) 70 | new_event 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /.todo.reek: -------------------------------------------------------------------------------- 1 | --- 2 | DuplicateMethodCall: 3 | exclude: 4 | - ActiveModel::Transitions#read_state 5 | - ActiveModel::Transitions#reload 6 | - ActiveModel::Transitions#set_initial_state 7 | - ActiveModel::Transitions#set_initial_state 8 | - Transitions::Event#error_message_for_invalid_transitions 9 | - Transitions::Event#fire 10 | - Transitions::Event#fire 11 | - Transitions::Event#initialize 12 | - Transitions::Event#initialize 13 | - Transitions::Event#update 14 | - Transitions::Machine#handle_event_success_callback 15 | - Transitions::Machine#handle_event_success_callback 16 | - Transitions::Machine#initial_state 17 | - Transitions::State#define_state_query_method 18 | IrresponsibleModule: 19 | exclude: 20 | - ActiveModel::Transitions 21 | - Transitions::Machine 22 | - Transitions::Presenter 23 | - Transitions::State 24 | - Transitions::StateTransition 25 | - Transitions 26 | - Transitions::ClassMethods 27 | - Transitions::InvalidMethodOverride 28 | - Transitions::InvalidTransition 29 | NilCheck: 30 | exclude: 31 | - ActiveModel::Transitions#state_not_set? 32 | - Transitions::Event#timestamp_defined? 33 | - Transitions::StateTransition#execute 34 | - Transitions#current_state 35 | UncommunicativeVariableName: 36 | exclude: 37 | - ActiveModel::Transitions#state_included? 38 | - Transitions::Event#can_execute_transition_from_state? 39 | - Transitions::Event#fire 40 | - Transitions::Event#transitions 41 | - Transitions::Event#transitions_from_state? 42 | - Transitions::Machine#fire_event 43 | - Transitions::StateTransition#executable? 44 | FeatureEnvy: 45 | exclude: 46 | - Transitions::Event#can_execute_transition_from_state? 47 | - Transitions::Event#default_timestamp_name 48 | - Transitions::Event#error_message_for_invalid_transitions 49 | NestedIterators: 50 | exclude: 51 | - Transitions::Event#build_success_callback 52 | TooManyInstanceVariables: 53 | exclude: 54 | - Transitions::Event 55 | - Transitions::Machine 56 | - Transitions::StateTransition 57 | TooManyStatements: 58 | exclude: 59 | - Transitions::Event#build_success_callback 60 | - Transitions::Event#fire 61 | - Transitions::Machine#fire_event 62 | - Transitions#current_state 63 | Attribute: 64 | exclude: 65 | - Transitions::Machine#events 66 | - Transitions::Machine#initial_state 67 | - Transitions::Machine#state_index 68 | - Transitions::Machine#states 69 | DataClump: 70 | exclude: 71 | - Transitions::Machine 72 | LongParameterList: 73 | exclude: 74 | - Transitions::Machine#fire_event 75 | UtilityFunction: 76 | exclude: 77 | - Transitions::Machine#handle_event_failed_callback 78 | - Transitions::Machine#handle_event_fired_callback 79 | - Transitions::StateTransition#perform_guard 80 | BooleanParameter: 81 | exclude: 82 | - Transitions#update_current_state 83 | ControlParameter: 84 | exclude: 85 | - Transitions#update_current_state 86 | -------------------------------------------------------------------------------- /lib/active_model/transitions.rb: -------------------------------------------------------------------------------- 1 | module ActiveModel 2 | module Transitions 3 | extend ActiveSupport::Concern 4 | 5 | included do 6 | ::Transitions::Machine.class_eval do 7 | unless method_defined?(:new_transitions_initialize) || method_defined?(:new_transitions_update) 8 | attr_reader :attribute_name 9 | alias_method :old_transitions_initialize, :initialize 10 | alias_method :old_transitions_update, :update 11 | 12 | def new_transitions_initialize(*args, &block) 13 | @attribute_name = :state 14 | old_transitions_initialize(*args, &block) 15 | end 16 | 17 | def new_transitions_update(options = {}, &block) 18 | @attribute_name = options[:attribute_name] if options.key?(:attribute_name) 19 | old_transitions_update(options, &block) 20 | end 21 | 22 | alias_method :initialize, :new_transitions_initialize 23 | alias_method :update, :new_transitions_update 24 | end 25 | end 26 | include ::Transitions 27 | after_initialize :set_initial_state 28 | validate :state_presence 29 | validate :state_inclusion 30 | end 31 | 32 | # The optional options argument is passed to find when reloading so you may 33 | # do e.g. record.reload(:lock => true) to reload the same record with an 34 | # exclusive row lock. 35 | def reload(*) 36 | super.tap do 37 | sm = self.class.get_state_machine 38 | remove_instance_variable(sm.current_state_variable) if instance_variable_defined?(sm.current_state_variable) 39 | end 40 | end 41 | 42 | protected 43 | 44 | def transitions_state_column_name 45 | self.class.state_machine.attribute_name 46 | end 47 | 48 | def write_state(state) 49 | prev_state = current_state 50 | write_state_without_persistence(state) 51 | save! 52 | rescue ActiveRecord::RecordInvalid 53 | write_state_without_persistence(prev_state) 54 | raise 55 | end 56 | 57 | def write_state_without_persistence(state) 58 | ivar = self.class.get_state_machine.current_state_variable 59 | instance_variable_set(ivar, state) 60 | self[transitions_state_column_name] = state.to_s 61 | end 62 | 63 | def read_state 64 | self[transitions_state_column_name] && self[transitions_state_column_name].to_sym 65 | end 66 | 67 | # 68 | # rubocop:disable Metrics/AbcSize 69 | # 70 | def set_initial_state 71 | # In case we use a query with a custom select that excludes our state attribute 72 | # name we need to skip the initialization below. 73 | return unless attribute_names.include?(transitions_state_column_name.to_s) && state_not_set? 74 | self[transitions_state_column_name] = self.class.get_state_machine.initial_state.to_s 75 | self.class.get_state_machine.state_index[self[transitions_state_column_name].to_sym].call_action(:enter, self) 76 | end 77 | 78 | def state_presence 79 | return if self[transitions_state_column_name].present? 80 | errors.add(transitions_state_column_name, :presence) 81 | end 82 | 83 | def state_inclusion 84 | return if state_included? 85 | errors.add(transitions_state_column_name, :inclusion, value: self[transitions_state_column_name]) 86 | end 87 | 88 | def state_not_set? 89 | self[transitions_state_column_name].nil? 90 | end 91 | 92 | def state_included? 93 | self.class.get_state_machine.states 94 | .map { |s| s.name.to_s } 95 | .include?(self[transitions_state_column_name].to_s) 96 | end 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /test/active_record/test_active_record_timestamps.rb: -------------------------------------------------------------------------------- 1 | require 'helper' 2 | 3 | class CreateOrders < ActiveRecord::Migration 4 | def self.up 5 | create_table(:orders, force: true) do |t| 6 | t.string :state 7 | t.string :order_number 8 | t.datetime :paid_at 9 | t.datetime :prepared_on 10 | t.datetime :dispatched_at 11 | t.date :cancellation_date 12 | t.boolean :allow_transition, default: true 13 | end 14 | end 15 | end 16 | 17 | class Order < ActiveRecord::Base 18 | include ActiveModel::Transitions 19 | 20 | state_machine do 21 | state :opened 22 | state :placed 23 | state :paid 24 | state :prepared 25 | state :delivered 26 | state :cancelled 27 | 28 | # no timestamp col is being specified here - should be ignored 29 | event :place do 30 | transitions from: :opened, to: :placed 31 | end 32 | 33 | # should set paid_at timestamp 34 | event :pay, timestamp: true do 35 | transitions from: :placed, to: :paid, guard: ->(obj) { obj.allow_transition } 36 | end 37 | 38 | # should set prepared_on 39 | event :prepare, timestamp: true do 40 | transitions from: :paid, to: :prepared 41 | end 42 | 43 | # should set dispatched_at 44 | event :deliver, timestamp: 'dispatched_at' do 45 | transitions from: :prepared, to: :delivered 46 | end 47 | 48 | # should set cancellation_date 49 | event :cancel, timestamp: :cancellation_date do 50 | transitions from: [:placed, :paid, :prepared], to: :cancelled 51 | end 52 | 53 | # should raise an exception as there is no timestamp col 54 | event :reopen, timestamp: true do 55 | transitions from: :cancelled, to: :opened 56 | end 57 | end 58 | end 59 | 60 | class TestActiveRecordTimestamps < Test::Unit::TestCase 61 | require 'securerandom' 62 | 63 | def setup 64 | set_up_db CreateOrders 65 | end 66 | 67 | def create_order(state = nil) 68 | Order.create! order_number: SecureRandom.hex(4), state: state 69 | end 70 | 71 | # control case, no timestamp has been set so we should expect default behaviour 72 | test 'moving to placed does not raise any exceptions' do 73 | @order = create_order 74 | assert_nothing_raised { @order.place! } 75 | assert_equal @order.state, 'placed' 76 | end 77 | 78 | test 'moving to paid should set paid_at' do 79 | @order = create_order(:placed) 80 | @order.pay! 81 | @order.reload 82 | assert_not_nil @order.paid_at 83 | end 84 | 85 | test 'moving to paid should not set paid_at if our guard evaluates to false' do 86 | @order = create_order(:placed) 87 | @order.update_attribute :allow_transition, false 88 | @order.pay! 89 | @order.reload 90 | assert_nil @order.paid_at 91 | end 92 | 93 | test 'moving to prepared should set prepared_on' do 94 | @order = create_order(:paid) 95 | @order.prepare! 96 | @order.reload 97 | assert_not_nil @order.prepared_on 98 | end 99 | 100 | test 'moving to delivered should set dispatched_at' do 101 | @order = create_order(:prepared) 102 | @order.deliver! 103 | @order.reload 104 | assert_not_nil @order.dispatched_at 105 | end 106 | 107 | test 'moving to cancelled should set cancellation_date' do 108 | @order = create_order(:placed) 109 | @order.cancel! 110 | @order.reload 111 | assert_not_nil @order.cancellation_date 112 | end 113 | 114 | test 'moving to reopened should raise an exception as there is no attribute' do 115 | @order = create_order(:cancelled) 116 | assert_raise(NoMethodError) { @order.re_open! } 117 | @order.reload 118 | end 119 | 120 | test 'passing an invalid value to timestamp options should raise an exception' do 121 | assert_raise(ArgumentError) do 122 | class Order < ActiveRecord::Base 123 | include ActiveModel::Transitions 124 | state_machine do 125 | event :replace, timestamp: 1 do 126 | transitions from: :prepared, to: :placed 127 | end 128 | end 129 | end 130 | end 131 | end 132 | end 133 | -------------------------------------------------------------------------------- /lib/transitions/machine.rb: -------------------------------------------------------------------------------- 1 | module Transitions 2 | class Machine 3 | attr_writer :initial_state 4 | attr_accessor :states, :events, :state_index 5 | attr_reader :klass, :auto_scopes 6 | 7 | def initialize(klass, options = {}, &block) 8 | @klass = klass 9 | @states = [] 10 | @state_index = {} 11 | @events = {} 12 | update(options, &block) 13 | end 14 | 15 | def initial_state 16 | @initial_state ||= (states.first ? states.first.name : nil) 17 | end 18 | 19 | def update(options = {}, &block) 20 | @initial_state = options[:initial] if options.key?(:initial) 21 | @auto_scopes = options[:auto_scopes] 22 | instance_eval(&block) if block 23 | include_scopes if @auto_scopes && ::Transitions.active_model_descendant?(klass) 24 | self 25 | end 26 | 27 | # 28 | # rubocop:disable Metrics/MethodLength 29 | # 30 | def fire_event(event, record, persist, *args) 31 | handle_state_exit_callback record 32 | if new_state = transition_to_new_state(record, event, *args) 33 | handle_state_enter_callback record, new_state 34 | handle_event_fired_callback record, new_state, event 35 | old_state = record.current_state 36 | record.update_current_state(new_state, persist) 37 | handle_event_success_callback record, event 38 | handle_event_succeeded_callback record, old_state, event 39 | return true 40 | else 41 | handle_event_failed_callback record, event 42 | return false 43 | end 44 | rescue => e 45 | raise e unless record.respond_to?(:event_failed) 46 | record.send(:event_failed, event) 47 | return false 48 | end 49 | 50 | def events_for(state) 51 | events = @events.values.select { |event| event.transitions_from_state?(state) } 52 | events.map!(&:name) 53 | end 54 | 55 | def current_state_variable 56 | # TODO: Refactor me away. 57 | :@current_state 58 | end 59 | 60 | private 61 | 62 | def handle_state_exit_callback(record) 63 | state_index[record.current_state].call_action(:exit, record) 64 | end 65 | 66 | def transition_to_new_state(record, event, *args) 67 | @events[event].fire(record, nil, *args) 68 | end 69 | 70 | def handle_state_enter_callback(record, new_state) 71 | state_index[new_state].call_action(:enter, record) 72 | end 73 | 74 | def handle_event_fired_callback(record, new_state, event) 75 | return unless record.respond_to?(:event_fired, true) 76 | record.send(:event_fired, record.current_state, new_state, event) 77 | end 78 | 79 | def handle_event_success_callback(record, event) 80 | @events[event].success.call(record) if @events[event].success 81 | end 82 | 83 | def handle_event_succeeded_callback(record, old_state, event) 84 | return unless record.respond_to?(:event_succeeded, true) 85 | record.send(:event_succeeded, old_state, record.current_state, event) 86 | end 87 | 88 | def handle_event_failed_callback(record, event) 89 | return unless record.respond_to?(:event_failed, true) 90 | record.send(:event_failed, event) 91 | end 92 | 93 | def state(name, options = {}) 94 | return if @state_index.key?(name) # Just ignore duplicates 95 | state = State.new(name, machine: self) 96 | state.update options 97 | @state_index[name] = state 98 | @states << state 99 | end 100 | 101 | def event(name, options = {}, &block) 102 | (@events[name] ||= Event.new(self, name)).update(options, &block) 103 | end 104 | 105 | # :reek:TooManyStatements: { max_statements: 7 } 106 | def include_scopes 107 | @states.each do |state| 108 | state_name = state.name.to_s 109 | if @klass.respond_to?(state_name) 110 | fail InvalidMethodOverride, 111 | "Transitions: Can not define scope `#{state_name}` because there is already"\ 112 | 'an equally named method defined - either rename the existing method or the state.' 113 | end 114 | scope = @klass.instance_exec { -> { where(state_machine.attribute_name => state_name) } } 115 | @klass.scope state_name, scope 116 | end 117 | end 118 | end 119 | end 120 | -------------------------------------------------------------------------------- /lib/transitions/event.rb: -------------------------------------------------------------------------------- 1 | module Transitions 2 | # rubocop:disable Metrics/ClassLength 3 | class Event 4 | attr_reader :name, :success, :timestamp 5 | 6 | # :reek:TooManyStatements: { max_statements: 13 } 7 | def initialize(machine, name, options = {}, &block) 8 | @machine = machine 9 | @name = name 10 | @transitions = [] 11 | if machine 12 | machine.klass.send(:define_method, "#{name}!") do |*args| 13 | machine.fire_event(name, self, true, *args) 14 | end 15 | 16 | machine.klass.send(:define_method, name.to_s) do |*args| 17 | machine.fire_event(name, self, false, *args) 18 | end 19 | 20 | machine.klass.send(:define_method, "can_#{name}?") do |*_args| 21 | machine.events_for(current_state).include?(name.to_sym) 22 | end 23 | 24 | machine.klass.send(:define_method, "can_execute_#{name}?") do |*args| 25 | event = name.to_sym 26 | 27 | send("can_#{name}?", *args) && 28 | machine.events[event].can_execute_transition_from_state?(current_state, self, *args) 29 | end 30 | end 31 | update(options, &block) 32 | end 33 | 34 | def fire(obj, to_state = nil, *args) 35 | transitions = @transitions.select { |t| t.from == obj.current_state } 36 | fail InvalidTransition, error_message_for_invalid_transitions(obj) if transitions.size == 0 37 | 38 | next_state = nil 39 | transitions.each do |transition| 40 | next if to_state && !Array(transition.to).include?(to_state) 41 | next unless transition.executable?(obj, *args) 42 | 43 | next_state = to_state || Array(transition.to).first 44 | transition.execute(obj, *args) 45 | update_event_timestamp(obj, next_state) if timestamp_defined? 46 | break 47 | end 48 | # Update timestamps on obj if a timestamp has been defined 49 | next_state 50 | end 51 | 52 | def transitions_from_state?(state) 53 | @transitions.any? { |t| t.from? state } 54 | end 55 | 56 | def can_execute_transition_from_state?(state, obj, *args) 57 | @transitions.select { |t| t.from? state }.any? { |t| t.executable?(obj, *args) } 58 | end 59 | 60 | def ==(other) 61 | if other.is_a? Symbol 62 | name == other 63 | else 64 | name == other.name 65 | end 66 | end 67 | 68 | # Has the timestamp option been specified for this event? 69 | def timestamp_defined? 70 | !@timestamp.nil? 71 | end 72 | 73 | def update(options = {}, &block) 74 | @success = build_success_callback(options[:success]) if options.key?(:success) 75 | self.timestamp = options[:timestamp] if options[:timestamp] 76 | instance_eval(&block) if block 77 | self 78 | end 79 | 80 | # update the timestamp attribute on obj 81 | def update_event_timestamp(obj, next_state) 82 | obj.send "#{timestamp_attribute_name(obj, next_state)}=", Time.now 83 | end 84 | 85 | # Set the timestamp attribute. 86 | # @raise [ArgumentError] timestamp should be either a String, Symbol or true 87 | def timestamp=(value) 88 | case value 89 | when String, Symbol, TrueClass 90 | @timestamp = value 91 | else 92 | fail ArgumentError, 'timestamp must be either: true, a String or a Symbol' 93 | end 94 | end 95 | 96 | private 97 | 98 | # Returns the name of the timestamp attribute for this event 99 | # If the timestamp was simply true it returns the default_timestamp_name 100 | # otherwise, returns the user-specified timestamp name 101 | def timestamp_attribute_name(obj, next_state) 102 | timestamp == true ? default_timestamp_name(obj, next_state) : @timestamp 103 | end 104 | 105 | # If @timestamp is true, try a default timestamp name 106 | def default_timestamp_name(obj, next_state) 107 | at_name = "#{next_state}_at" 108 | on_name = "#{next_state}_on" 109 | case 110 | when obj.respond_to?(at_name) then at_name 111 | when obj.respond_to?(on_name) then on_name 112 | else 113 | fail NoMethodError, "Couldn't find a suitable timestamp field for event: #{@name}. 114 | Please define #{at_name} or #{on_name} in #{obj.class}" 115 | end 116 | end 117 | 118 | def transitions(trans_opts) 119 | Array(trans_opts[:from]).each do |s| 120 | @transitions << StateTransition.new(trans_opts.merge(from: s.to_sym)) 121 | end 122 | end 123 | 124 | def build_success_callback(callback_names) 125 | case callback_names 126 | when Array 127 | lambda do |record| 128 | callback_names.each do |callback| 129 | build_success_callback(callback).call(record) 130 | end 131 | end 132 | when Proc 133 | callback_names 134 | when Symbol 135 | ->(record) { record.send(callback_names) } 136 | end 137 | end 138 | 139 | def error_message_for_invalid_transitions(obj) 140 | "Can't fire event `#{name}` in current state `#{obj.current_state}` for `#{obj.class.name}`"\ 141 | " #{obj.class < ActiveRecord::Base && obj.persisted? ? "with ID #{obj.id} " : nil}" 142 | end 143 | end 144 | end 145 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 1.1.1 2 | 3 | * (beornborn) use instance_exec for defining scope because @klass sometimes is resolved as nil 4 | 5 | # 1.1.0 6 | 7 | * (itkin) `available_states` and `available_events` should list states or events in the order they are defined 8 | 9 | # 1.0.1 10 | 11 | * (avyy) Fix @state_machine lookup for Presenter's methods 12 | 13 | # 1.0.0 14 | 15 | * (troessner) Only support ruby 2.1 and 2.2. 16 | 17 | # 0.2.1 18 | 19 | * (ozydingo / Andrew Schwartz) Don't use ActiveModel#has_attribute? since this adds the attribute name to self.attribute_names 20 | 21 | # 0.2.0 22 | 23 | * (troessner) Fix missing explicit returns in our callback handling 24 | * (Lee Henson) add can_execute_event? to check against guard clauses 25 | * (Randy Schmidt / r38y) Make reading state work for non-AR models. 26 | 27 | # 0.1.13 28 | 29 | * (Nathan Amick and Ryan Long) Allow passing non-Proc callables as guards 30 | * (troessner) Bugfixes, Refactorings and documentation improvements 31 | 32 | # 0.1.12 33 | 34 | * (troessner) Fix issue 107: timestamps are updated even if guard fails. 35 | 36 | # 0.1.10 37 | 38 | * (troessner) Fix: Using a custom ActiveRecord select query without the name of the state attribute would trigger an exception. 39 | 40 | # 0.1.9 41 | 42 | * (barelyknow) add can_transition? and cant_transition? methods 43 | * (barelyknow) add available_events method to class with state machine 44 | * (Sean Devine) fire enter on create 45 | * (jotto) change scopes to use proc so it works in rails 3 and 4 46 | 47 | # 0.1.8 48 | 49 | * (phillipp | Phillipp Röll) Fixes a wrong select for scopes if the state machines attribute_name is set to something other than the default 50 | 51 | # 0.1.7 52 | 53 | * (cmw) Better prevention of name clashes when creating initializer methods 54 | 55 | # 0.1.6 56 | 57 | * (troessner) Revert 'Fixing set_initial_state with Mongoid' because of https://github.com/troessner/transitions/issues/76 58 | * (divins) Multiple success callbacks 59 | * (cstrahan) Pass additional args to guard function 60 | * (cmw) Support for configurable column names 61 | 62 | # 0.1.5 63 | 64 | * (troessner) Fix unhelpful error message when event can not be fired. 65 | * (simonc) Fixing set_initial_state with Mongoid 66 | 67 | # 0.1.4 68 | 69 | (troessner) 70 | 71 | * Raise exception if we try to overwrite existing instance methods when defining state predicate methods 72 | * Improve exception message if we try to overwrite existing class methods when ActiveRecord scopes 73 | 74 | # 0.1.3 75 | 76 | (troessner) Make sure `empty?` works on symbols. 77 | 78 | # 0.1.2 79 | 80 | (troessner) Slightly improved handling of current states. 81 | 82 | # 0.1.1 83 | 84 | * (troessner) Remove feature: 85 | `Do not override existing methods when defining state query methods but warn the user.` 86 | since this turned out to cause some problems when loading models, see 87 | https://github.com/troessner/transitions/issues/62 for details. 88 | * (bnmrrs) Add helper methods to check if a given transition is possible. 89 | 90 | # 0.1.0 91 | 92 | (troessner) Remove suppport for multipe state machines 93 | 94 | Here is the reasoning: 95 | 96 | 1.) If you really need multiple state machines for one model, you probably need multiple models. 97 | So far, I have not seen a valid use case for multiple state machines, which was not better expressed 98 | by using multiple models. 99 | 100 | 2.) The current transitions semantics and API is not suited for multiple state machines: 101 | Right now events are just plain methods defined on the models itself. 102 | Consider you had multiple state machines, on named `day` and one named `night`, both with differing events. 103 | What to do when you switch to state machine `day` but trigger an event defined on `night`? 104 | You can either allow it, but this would make multiple state machines pointless if there is no enforced 105 | separation of concerns. Or you disallow it, in which case `events` would either have to be bound 106 | to state machines, not to the object itself, which would be tedious and ugly, or the `events` themselves 107 | would need to check if they are allowed to be called. The last solution seems like a clean solution 108 | but it would require a decent amount of rewriting existing code - and I just do not see the benefit for this. 109 | 110 | 3.) Kind of a weak point, but right now the functionality is broken anyway throughout the gem. This functionality 111 | is not even documented, so the side effects on existing projects should be minimal. 112 | 113 | On the plus side, removing the possibility of having multiple state machines will streamline and improve existing 114 | code a lot. 115 | 116 | # 0.0.18 (2012-05-18) 117 | 118 | * (troessner) Remove `define_state_query_method` from public API 119 | * (troessner) Do not override existing methods when defining state query methods but warn the user. 120 | 121 | # 0.0.17 (2012-05-02): 122 | 123 | * (zmillman) Add write_state_without_persistence. 124 | 125 | # 0.0.16 (2012-04-18): 126 | 127 | * (mperham) Remove backports, fix Ruby 1.8 support. 128 | 129 | # 0.0.15 (2012-04-17): 130 | 131 | * (troessner) Ensure ruby 1.8.7 compatibility. 132 | 133 | # 0.0.14 (2012-04-16): 134 | 135 | * (troessner) Improve error messages for invalid transitions. 136 | -------------------------------------------------------------------------------- /test/active_record/test_active_record.rb: -------------------------------------------------------------------------------- 1 | require 'helper' 2 | 3 | class CreateTrafficLights < ActiveRecord::Migration 4 | def self.up 5 | create_table(:traffic_lights, force: true) do |t| 6 | t.string :state 7 | t.string :name 8 | t.string :power 9 | end 10 | end 11 | end 12 | 13 | class CreateDifferentTrafficLights < ActiveRecord::Migration 14 | def self.up 15 | create_table(:different_traffic_lights) do |t| 16 | t.string :different_state 17 | t.string :name 18 | end 19 | end 20 | end 21 | 22 | class TrafficLight < ActiveRecord::Base 23 | include ActiveModel::Transitions 24 | 25 | state_machine auto_scopes: true do 26 | state :off, enter: :turn_power_on 27 | 28 | state :red 29 | state :green 30 | state :yellow 31 | 32 | event :red_on do 33 | transitions to: :red, from: [:yellow] 34 | end 35 | 36 | event :green_on do 37 | transitions to: :green, from: [:red] 38 | end 39 | 40 | event :yellow_on do 41 | transitions to: :yellow, from: [:green] 42 | end 43 | 44 | event :reset do 45 | transitions to: :red, from: [:off] 46 | end 47 | end 48 | 49 | def turn_power_on 50 | fail 'the power should not have been on already' if power == 'on' 51 | self.power = 'on' 52 | end 53 | end 54 | 55 | class ValidatingTrafficLight < TrafficLight 56 | validate { |t| errors.add(:base, 'This TrafficLight will never validate after creation') unless t.new_record? } 57 | end 58 | 59 | class ConditionalValidatingTrafficLight < TrafficLight 60 | validates(:name, presence: true, if: :red?) 61 | end 62 | 63 | class TestActiveRecord < Test::Unit::TestCase 64 | def setup 65 | set_up_db CreateTrafficLights 66 | @light = TrafficLight.create! 67 | end 68 | 69 | test 'new record has the initial state set' do 70 | @light = TrafficLight.new 71 | assert_equal 'off', @light.state 72 | end 73 | 74 | test 'new active records defaults current state to the initial state' do 75 | assert_equal :off, @light.current_state 76 | end 77 | 78 | test 'states initial state' do 79 | assert @light.off? 80 | assert_equal :off, @light.current_state 81 | end 82 | 83 | test 'calls enter when setting the initial state' do 84 | @new_light = TrafficLight.new 85 | assert_equal 'on', @new_light.power 86 | end 87 | 88 | test 'does not call enter when loading a persisted record' do 89 | assert_equal 'on', @light.power 90 | assert_nothing_raised { TrafficLight.find(@light.id) } 91 | end 92 | 93 | test 'transition to a valid state' do 94 | @light.reset 95 | assert @light.red? 96 | assert_equal :red, @light.current_state 97 | 98 | @light.green_on 99 | assert @light.green? 100 | assert_equal :green, @light.current_state 101 | end 102 | 103 | test 'transition does not persist state' do 104 | @light.reset 105 | assert_equal :red, @light.current_state 106 | @light.reload 107 | assert_equal 'off', @light.state 108 | end 109 | 110 | test 'transition does persists state' do 111 | @light.reset! 112 | assert_equal :red, @light.current_state 113 | @light.reload 114 | assert_equal 'red', @light.state 115 | end 116 | 117 | test 'transition to an invalid state' do 118 | assert_raise(Transitions::InvalidTransition) { @light.yellow_on } 119 | assert_equal :off, @light.current_state 120 | end 121 | 122 | test 'transition with wrong state will not validate' do 123 | for s in @light.class.get_state_machine.states 124 | @light.state = s.name 125 | assert @light.valid? 126 | end 127 | @light.state = 'invalid_one' 128 | assert_false @light.valid? 129 | end 130 | 131 | test 'transition raises exception when model validation fails' do 132 | validating_light = ValidatingTrafficLight.create!(name: 'Foobar') 133 | assert_raise(ActiveRecord::RecordInvalid) do 134 | validating_light.reset! 135 | end 136 | end 137 | 138 | test 'state query method used in a validation condition' do 139 | validating_light = ConditionalValidatingTrafficLight.create! 140 | assert_raise(ActiveRecord::RecordInvalid) do 141 | validating_light.reset! 142 | end 143 | assert(validating_light.off?) 144 | end 145 | 146 | test 'reloading model resets current state' do 147 | @light.reset 148 | assert @light.red? 149 | @light.update_attribute(:state, 'green') 150 | assert @light.reload.green?, 'reloaded state should come from database, not instance variable' 151 | end 152 | 153 | test 'calling non-bang event updates state attribute' do 154 | @light.reset! 155 | assert @light.red? 156 | @light.green_on 157 | assert_equal 'green', @light.state 158 | assert_equal 'red', @light.reload.state 159 | end 160 | end 161 | 162 | if ActiveRecord::VERSION::MAJOR == 3 163 | 164 | class TestMassAssignmentActiveRecord < Test::Unit::TestCase 165 | # attr_protected unfortunately invokes a db call, so this test requires that 166 | # we define the class after the table already exists. 167 | def setup 168 | set_up_db CreateTrafficLights 169 | 170 | @light_with_protected_state = Class.new(TrafficLight) do 171 | attr_protected :state 172 | end 173 | end 174 | 175 | test 'transition does persists state when state is protected' do 176 | protected_light = @light_with_protected_state.create! 177 | protected_light.reset! 178 | assert_equal :red, protected_light.current_state 179 | protected_light.reload 180 | assert_equal 'red', protected_light.state 181 | end 182 | end 183 | end 184 | 185 | class TestNewActiveRecord < TestActiveRecord 186 | def setup 187 | set_up_db CreateTrafficLights 188 | @light = TrafficLight.new 189 | end 190 | 191 | test 'new active records defaults current state to the initial state' do 192 | assert_equal :off, @light.current_state 193 | end 194 | end 195 | 196 | class TestScopes < Test::Unit::TestCase 197 | test 'scope returns correct object' do 198 | @light = TrafficLight.create! 199 | assert_respond_to TrafficLight, :off 200 | assert_equal TrafficLight.off.first, @light 201 | assert TrafficLight.red.empty? 202 | end 203 | 204 | test 'scopes exist' do 205 | assert_respond_to TrafficLight, :off 206 | assert_respond_to TrafficLight, :red 207 | assert_respond_to TrafficLight, :green 208 | assert_respond_to TrafficLight, :yellow 209 | end 210 | 211 | test 'scopes are only generated if we explicitly say so' do 212 | assert_not_respond_to LightBulb, :off 213 | assert_not_respond_to LightBulb, :on 214 | end 215 | 216 | test 'scope generation raises an exception if we try to overwrite an existing method' do 217 | assert_raise(Transitions::InvalidMethodOverride) do 218 | class Light < ActiveRecord::Base 219 | include ActiveModel::Transitions 220 | 221 | state_machine auto_scopes: true do 222 | state :new 223 | state :broken 224 | end 225 | end 226 | end 227 | end 228 | end 229 | 230 | class DifferentTrafficLight < ActiveRecord::Base 231 | include ActiveModel::Transitions 232 | 233 | state_machine attribute_name: :different_state, auto_scopes: true do 234 | state :off 235 | 236 | state :red 237 | state :green 238 | state :yellow 239 | 240 | event :red_on do 241 | transitions to: :red, from: [:yellow] 242 | end 243 | 244 | event :green_on do 245 | transitions to: :green, from: [:red] 246 | end 247 | 248 | event :yellow_on do 249 | transitions to: :yellow, from: [:green] 250 | end 251 | 252 | event :reset do 253 | transitions to: :red, from: [:off] 254 | end 255 | end 256 | end 257 | 258 | class TestActiveRecordWithDifferentColumnName < Test::Unit::TestCase 259 | def setup 260 | set_up_db CreateDifferentTrafficLights 261 | @light = DifferentTrafficLight.create! 262 | end 263 | 264 | test 'new record has the initial state set' do 265 | @light = DifferentTrafficLight.new 266 | assert_equal 'off', @light.different_state 267 | end 268 | 269 | test 'states initial state' do 270 | assert @light.off? 271 | assert_equal :off, @light.current_state 272 | end 273 | 274 | test 'transition to a valid state' do 275 | @light.reset 276 | assert @light.red? 277 | assert_equal :red, @light.current_state 278 | 279 | @light.green_on 280 | assert @light.green? 281 | assert_equal :green, @light.current_state 282 | end 283 | 284 | test 'transition does not persist state' do 285 | @light.reset 286 | assert_equal :red, @light.current_state 287 | @light.reload 288 | assert_equal 'off', @light.different_state 289 | end 290 | 291 | test 'transition does persists state' do 292 | @light.reset! 293 | assert_equal :red, @light.current_state 294 | @light.reload 295 | assert_equal 'red', @light.different_state 296 | end 297 | 298 | test 'transition to an invalid state' do 299 | assert_raise(Transitions::InvalidTransition) { @light.yellow_on } 300 | assert_equal :off, @light.current_state 301 | end 302 | 303 | test 'transition with wrong state will not validate' do 304 | for s in @light.class.state_machine.states 305 | @light.different_state = s.name 306 | assert @light.valid? 307 | end 308 | @light.different_state = 'invalid_one' 309 | assert_false @light.valid? 310 | end 311 | 312 | test 'reloading model resets current state' do 313 | @light.reset 314 | assert @light.red? 315 | @light.update_attribute(:different_state, 'green') 316 | assert @light.reload.green?, 'reloaded state should come from database, not instance variable' 317 | end 318 | 319 | test 'calling non-bang event updates state attribute' do 320 | @light.reset! 321 | assert @light.red? 322 | @light.green_on 323 | assert_equal 'green', @light.different_state 324 | assert_equal 'red', @light.reload.different_state 325 | end 326 | end 327 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ### Overview 2 | [![Build status](https://secure.travis-ci.org/troessner/transitions.png?branch=master)](http://travis-ci.org/troessner/transitions) 3 | [![Gem Version](https://badge.fury.io/rb/transitions.png)](http://badge.fury.io/rb/transitions) 4 | [![Code Climate](https://codeclimate.com/github/troessner/transitions.png)](https://codeclimate.com/github/troessner/transitions) 5 | [![Dependency Status](https://gemnasium.com/troessner/transitions.png)](https://gemnasium.com/troessner/transitions) 6 | [![Inline docs](http://inch-ci.org/github/troessner/transitions.png)](http://inch-ci.org/github/troessner/transitions) 7 | 8 | 9 | ### Synopsis 10 | 11 | `transitions` is a ruby state machine implementation. 12 | 13 | ### Installation 14 | 15 | #### Rails 16 | 17 | This goes into your Gemfile: 18 | ```ruby 19 | gem "transitions", :require => ["transitions", "active_model/transitions"] 20 | ``` 21 | 22 | … and this into your ORM model: 23 | ```ruby 24 | include ActiveModel::Transitions 25 | ``` 26 | 27 | #### Standalone 28 | ```shell 29 | gem install transitions 30 | ``` 31 | 32 | … and this into your class: 33 | ```ruby 34 | include Transitions 35 | ``` 36 | 37 | ### Using transitions 38 | ```ruby 39 | class Product 40 | include ActiveModel::Transitions 41 | 42 | state_machine do 43 | state :available # first one is initial state 44 | state :out_of_stock, :exit => :exit_out_of_stock 45 | state :discontinued, :enter => lambda { |product| product.cancel_orders } 46 | 47 | event :discontinued do 48 | transitions :to => :discontinued, :from => [:available, :out_of_stock], :on_transition => :do_discontinue 49 | end 50 | event :out_of_stock, :success => :reorder do 51 | transitions :to => :out_of_stock, :from => [:available, :discontinued] 52 | end 53 | event :available do 54 | transitions :to => :available, :from => [:out_of_stock], :guard => lambda { |product| product.in_stock > 0 } 55 | end 56 | end 57 | end 58 | ``` 59 | In this example we assume that you are in a rails project using Bundler, which 60 | would automatically require `transitions`. If this is not the case then you 61 | have to add 62 | ```ruby 63 | require 'transitions' 64 | ``` 65 | 66 | wherever you load your dependencies in your application. 67 | 68 | **Known limitations:** 69 | 70 | * You can only use one state machine per model. While in theory you can 71 | define two or more, this won't work as you would expect. Not supporting 72 | this was intentional, if you're interested in the rational look up version 73 | 0.1.0 in the CHANGELOG. 74 | 75 | * Use symbols, not strings for declaring the state machine. Using strings is 76 | **not** supported as is using whitespace in names (because `transitions` 77 | possibly generates methods out of this). 78 | 79 | ### Features 80 | 81 | #### Getting and setting the current state 82 | 83 | Use the (surprise ahead) `current_state` method - in case you didn't set a 84 | state explicitly you'll get back the state that you defined as initial state. 85 | ```ruby 86 | >> Product.new.current_state 87 | => :available 88 | ``` 89 | 90 | You can also set a new state explicitly via `update_current_state(new_state, 91 | persist = true / false)` but you should never do this unless you really know 92 | what you're doing and why - rather use events / state transitions (see below). 93 | 94 | Predicate methods are also available using the name of the state. 95 | ```ruby 96 | >> Product.new.available? 97 | => true 98 | ``` 99 | 100 | #### Events 101 | 102 | When you declare an event, say `discontinue`, three methods are declared for 103 | you: `discontinue`, `discontinue!` and `can_discontinue?`. The first two 104 | events will modify the `state` attribute on successful transition, but only 105 | the bang(!)-version will call `save!`. The `can_discontinue?` method will not 106 | modify state but instead returns a boolean letting you know if a given 107 | transition is possible. 108 | 109 | In addition, a `can_transition?` method is added to the object that expects one or more event names as arguments. This semi-verbose method name is used to avoid collisions with [https://github.com/ryanb/cancan](the authorization gem CanCan). 110 | ```ruby 111 | >> Product.new.can_transition? :out_of_stock 112 | => true 113 | ``` 114 | 115 | If you need to get all available transitions for the current state you can simply call: 116 | ```ruby 117 | >> Product.new.available_transitions 118 | => [:discontinued, :out_of_stock] 119 | ``` 120 | 121 | 122 | #### Callback overview 123 | 124 | `transitions` offers you the possibility to define a couple of callbacks during the different stages / places of transitioning from one state to another. So let's say you have an event `discontinue` which transitions the current state from `in_stock` to `sold_out`. The callback sequence would look like this: 125 | 126 | 127 | | discontinue event | 128 | | 129 | | 130 | | 131 | | current_state `in_stock` | ----> executes `exit` callback 132 | | 133 | | 134 | | 135 | | current_state `in_stock` | ----> executes `on_transition` callback if and only the `guard` check was successfull. If not successfull, the chain aborts here and the `event_failed` callback is executed 136 | | 137 | | 138 | | 139 | | current_state `in_stock` | ----> executes `enter` callback for new state `sold_out` 140 | | 141 | | 142 | | 143 | | current_state `in_stock` | ----> executes `event_fired` callback 144 | | 145 | | 146 | | 147 | | current_state `in_stock` | ----> move state from `in_stock` to `sold_out` 148 | | 149 | | 150 | | 151 | | current_state `sold_out` | ----> executes `success` callback of the `discontinue` event 152 | 153 | 154 | 155 | This all looks very complicated (I know), but don't worry, in 99% of all cases you don't have to care about the details and the usage itself is straightforward as you can see in the examples below where each callback is explained a little more throrough. 156 | 157 | 158 | #### Callback # 1: State callbacks `enter` and `exit` 159 | 160 | If you want to trigger a method call when the object enters or exits a state regardless 161 | of the transition that made that happen, use `enter` and `exit`. 162 | 163 | `exit` will be called before the transition out of the state is executed. If you want the method 164 | to only be called if the transition is successful, then use another approach. 165 | 166 | `enter` will be called after the transition has been made but before the object is persisted. If you want 167 | the method to only be called after a successful transition to a new state including persistence, 168 | use the `success` argument to an event instead. 169 | 170 | An example: 171 | 172 | ```ruby 173 | class Motor < ActiveRecord::Base 174 | include ActiveModel::Transitions 175 | 176 | state_machine do 177 | state :off, enter: :turn_power_off 178 | state :on, exit: :prepare_shutdown 179 | end 180 | end 181 | ``` 182 | 183 | #### Callback # 2: Transition callback `on_transition` 184 | 185 | 186 | Each event definition takes an optional `on_transition` argument, which allows 187 | you to execute code on transition. This callback is executed after the `exit` callback of the former state (if it has been defined) but before the `enter` callback of the new state and only if the `guard` check succeeds. There is no check if the callback itself succeeds (meaning that `transitions` does not evaluate its return value somewhere). However, you can easily add some properly abstracted error handling yourself by raising an exception in this callback and then handling this exception in the (also defined by you) `event_failed` callback (see below and / or the wonderful ascii diagram above). 188 | 189 | You can pass in a Symbol, a String, a Proc or an Array containing method names 190 | as Symbol or String like this: 191 | ```ruby 192 | event :discontinue do 193 | transitions :to => :discontinued, :from => [:available, :out_of_stock], :on_transition => [:do_discontinue, :notify_clerk] 194 | end 195 | ``` 196 | 197 | Any arguments passed to the event method will be passed on to the `on_transition` callback. 198 | 199 | 200 | #### Callback #3 : Event callback `success` 201 | 202 | In case you need to trigger a method call after a successful transition you 203 | can use `success`. This will be called after the `save!` is complete (if you 204 | use the `state_name!` method) and should be used for any methods that require 205 | that the object be persisted. 206 | ```ruby 207 | event :discontinue, :success => :notify_admin do 208 | transitions :to => :discontinued, :from => [:available, :out_of_stock] 209 | end 210 | ``` 211 | 212 | In addition to just specify the method name on the record as a symbol you can 213 | pass a lambda to perfom some more complex success callbacks: 214 | ```ruby 215 | event :discontinue, :success => lambda { |order| AdminNotifier.notify_about_discontinued_order(order) } do 216 | transitions :to => :discontinued, :from => [:available, :out_of_stock] 217 | end 218 | ``` 219 | 220 | If you need it, you can even call multiple methods or lambdas just passing an 221 | array: 222 | ```ruby 223 | event :discontinue, :success => [:notify_admin, lambda { |order| AdminNotifier.notify_about_discontinued_order(order) }] do 224 | transitions :to => :discontinued, :from => [:available, :out_of_stock] 225 | end 226 | ``` 227 | 228 | #### Callback caveats 229 | 230 | Since callbacks will not be called by you but by `transitions` the scope is different when they are called and you'll run into problems if you use classes / modules in those callbacks that have the same names like `transitions` ones, e.g. "Event": 231 | 232 | ```Ruby 233 | def event_fired(current_state, new_state, event) 234 | Event.create! 235 | end 236 | ``` 237 | 238 | This will crash because `transitions` uses an Event class as well, and, since the scope has changed when `transitions` calls this method, `transitions` will use it's own Event class here, not yours. 239 | In this case you can try to prefix your models with the "::" operator and see if that solves your problems. See https://github.com/troessner/transitions/issues/123 for details. 240 | 241 | #### Automatic scope generation 242 | 243 | `transitions` will automatically generate scopes for you if you are using 244 | ActiveRecord and tell it to do so via the `auto_scopes` option: 245 | 246 | Given a model like this: 247 | ```ruby 248 | class Order < ActiveRecord::Base 249 | include ActiveModel::Transitions 250 | state_machine :auto_scopes => true do 251 | state :pick_line_items 252 | state :picking_line_items 253 | event :move_cart do 254 | transitions to: :pick_line_items, from: :picking_line_items 255 | end 256 | end 257 | end 258 | ``` 259 | 260 | you can use this feature a la: 261 | ```ruby 262 | >> Order.pick_line_items 263 | => [] 264 | >> Order.create! 265 | => # 266 | >> Order.pick_line_items 267 | => [#] 268 | ``` 269 | 270 | #### Using `guard` 271 | 272 | Each event definition takes an optional `guard` argument, which acts as a 273 | predicate for the transition. 274 | 275 | You can pass in Symbols, Strings, or Procs like this: 276 | ```ruby 277 | event :discontinue do 278 | transitions :to => :discontinued, :from => [:available, :out_of_stock], :guard => :can_discontinue 279 | end 280 | ``` 281 | 282 | or 283 | ```ruby 284 | event :discontinue do 285 | transitions :to => :discontinued, :from => [:available, :out_of_stock], :guard => [:can_discontinue, :super_sure?] 286 | end 287 | ``` 288 | 289 | Any arguments passed to the event method will be passed on to the `guard` 290 | predicate. 291 | 292 | Note that guards will **not** raise on failure on their own. This means that if you want to 293 | treat the failure of a guard exceptional you'll need to raise an exception yourself explicitly 294 | in that guard (see [here](https://github.com/troessner/transitions/issues/149) for the 295 | corresponding discussion). 296 | 297 | 298 | #### Timestamps 299 | 300 | If you'd like to note the time of a state change, Transitions comes with 301 | timestamps free! To activate them, simply pass the `timestamp` option to the 302 | event definition with a value of either true or the name of the timestamp 303 | column. *NOTE - This should be either true, a String or a Symbol* 304 | ```ruby 305 | # This will look for an attribute called exploded_at or exploded_on (in that order) 306 | # If present, it will be updated 307 | event :explode, :timestamp => true do 308 | transitions :from => :complete, :to => :exploded 309 | end 310 | 311 | # This will look for an attribute named repaired_on to update upon save 312 | event :rebuild, :timestamp => :repaired_on do 313 | transitions :from => :exploded, :to => :rebuilt 314 | end 315 | ``` 316 | 317 | #### Using `event_fired` and `event_failed` 318 | 319 | In case you define `event_fired` and / or `event_failed`, `transitions` will 320 | use those callbacks correspondingly. 321 | 322 | You can use those callbacks like this: 323 | ```ruby 324 | def event_fired(current_state, new_state, event) 325 | MyLogger.info "Event fired #{event.inspect}" 326 | end 327 | 328 | def event_failed(event) 329 | MyLogger.warn "Event failed #{event.inspect}" 330 | end 331 | ``` 332 | 333 | #### Listing all the available states and events 334 | 335 | You can easily get a listing of all available states: 336 | ```ruby 337 | Order.available_states # Uses the default state machine 338 | # => [:pick_line_items, :picking_line_items] 339 | ``` 340 | 341 | Same goes for the available events: 342 | ```ruby 343 | Order.available_events 344 | # => [:move_cart] 345 | ``` 346 | 347 | #### Explicitly setting the initial state with the `initial` option 348 | ```ruby 349 | state_machine :initial => :closed do 350 | state :open 351 | state :closed 352 | end 353 | ``` 354 | 355 | The explicitly specified state **must** be one of the states listed in the state definition below, otherwise `transitions` will raise a rather unhelpful exception like "NoMethodError: undefined method `call_action' for nil:NilClass" (there's a ticket to fix this already: https://github.com/troessner/transitions/issues/112) 356 | 357 | 358 | ### Configuring a different column name with ActiveRecord 359 | 360 | To use a different column than `state` to track it's value simply do this: 361 | ```ruby 362 | class Product < ActiveRecord::Base 363 | include Transitions 364 | 365 | state_machine :attribute_name => :different_column do 366 | 367 | ... 368 | 369 | end 370 | end 371 | ``` 372 | 373 | ### Ruby Compatibility 374 | 375 | Supported versions: 376 | 377 | * 2.1 378 | * 2.2 379 | 380 | Supported implementations: 381 | 382 | * MRI 383 | * Rubinius 384 | * Jruby 385 | 386 | ### Supported Rails versions: 387 | 388 | * 3 389 | * 4 390 | 391 | ### Known bugs / limitations 392 | 393 | * Right now it seems like `transitions` does not play well with `mongoid`. A 394 | possible fix had to be rolled back due to other side effects: 395 | https://github.com/troessner/transitions/issues/76. Since I know virtually 396 | zero about mongoid, a pull request would be highly appreciated. 397 | * Multiple state machines are not and will not be supported. For the rationale 398 | behind this see the Changelog. 399 | 400 | 401 | ### Associated projects 402 | 403 | * [GraphvizTransitions](https://github.com/itkin/graphviz_transitions) - Adds support for generating graphs based on states, events and transitions 404 | 405 | ### Documentation, Guides & Examples 406 | 407 | * [Online API Documentation](http://rdoc.info/github/troessner/transitions/master/Transitions) 408 | * [Railscasts #392: A Tour of State Machines](http://railscasts.com/episodes/392-a-tour-of-state-machines) (requires Pro subscription) 409 | 410 | 411 | ### Copyright 412 | 413 | Copyright (c) 2010 Jakub Kuźma, Timo Rößner. See LICENSE for details. 414 | --------------------------------------------------------------------------------