edge-state-machine 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
data/.gitignore ADDED
@@ -0,0 +1,4 @@
1
+ *.gem
2
+ .bundle
3
+ Gemfile.lock
4
+ pkg/*
data/.rspec ADDED
@@ -0,0 +1,4 @@
1
+ --colour
2
+ #--drb
3
+ --format documentation
4
+ #--fail-fast
data/.travis.yml ADDED
@@ -0,0 +1 @@
1
+ rvm: 1.9.2
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source "http://rubygems.org"
2
+
3
+ # Specify your gem's dependencies in edge-state-machine.gemspec
4
+ gemspec
data/README.rdoc ADDED
@@ -0,0 +1,63 @@
1
+ = Travis Build Status
2
+
3
+ {<img src="https://secure.travis-ci.org/danpersa/edge-state-machine.png"/>}[http://travis-ci.org/danpersa/edge-state-machine]
4
+
5
+ = Edge State Machine
6
+
7
+ The gem is based on Rick Olson's code of ActiveModel::StateMachine,
8
+ axed from ActiveModel in {this
9
+ commit}[http://github.com/rails/rails/commit/db49c706b62e7ea2ab93f05399dbfddf5087ee0c].
10
+
11
+ And on Krzysiek Heród's gem, {Transitions}[https://github.com/netizer/transitions], which added Mongoid support.
12
+
13
+ == Installation
14
+
15
+ If you're using Rails + ActiveRecord + Bundler
16
+
17
+ # in your Gemfile
18
+ gem "edge-state-machine", :require => ["edge-state-machine", "active_record/edge-state-machine"]
19
+
20
+ # in your AR models that will use the state machine
21
+ include ::EdgeStateMachine
22
+ include ActiveRecord::EdgeStateMachine
23
+
24
+ state_machine do
25
+ state :available # first one is initial state
26
+ state :out_of_stock
27
+ state :discontinue
28
+
29
+ event :discontinue do
30
+ transitions :to => :discontinue, :from => [:available, :out_of_stock], :on_transition => :do_discontinue
31
+ end
32
+ event :out_of_stock do
33
+ transitions :to => :out_of_stock, :from => [:available, :discontinue]
34
+ end
35
+ event :available do
36
+ transitions :to => :available, :from => [:out_of_stock], :on_transition => :send_alerts
37
+ end
38
+ end
39
+
40
+ If you're using Rails + Mongoid + Bundler
41
+
42
+ # in your Gemfile
43
+ gem "edge-state-machine", :require => ["edge-state-machine", "mongoid/edge-state-machine"]
44
+
45
+ # in your AR models that will use the state machine
46
+ include ::EdgeStateMachine
47
+ include Mongoid::EdgeStateMachine
48
+
49
+ state_machine do
50
+ state :available # first one is initial state
51
+ state :out_of_stock
52
+ state :discontinue
53
+
54
+ event :discontinue do
55
+ transitions :to => :discontinue, :from => [:available, :out_of_stock], :on_transition => :do_discontinue
56
+ end
57
+ event :out_of_stock do
58
+ transitions :to => :out_of_stock, :from => [:available, :discontinue]
59
+ end
60
+ event :available do
61
+ transitions :to => :available, :from => [:out_of_stock], :on_transition => :send_alerts
62
+ end
63
+ end
data/Rakefile ADDED
@@ -0,0 +1,20 @@
1
+ require 'rubygems'
2
+ require 'bundler'
3
+ require 'bundler/gem_tasks'
4
+
5
+
6
+ Bundler::GemHelper.install_tasks
7
+ Bundler.setup
8
+
9
+ require 'rake'
10
+
11
+ begin
12
+ require 'rspec/core/rake_task'
13
+ desc 'Run RSpecs to confirm that all functionality is working as expected'
14
+ RSpec::Core::RakeTask.new('spec') do |t|
15
+ t.pattern = 'spec/**/*_spec.rb'
16
+ end
17
+ task :default => :spec
18
+ rescue LoadError
19
+ puts "Hiding spec tasks because RSpec is not available"
20
+ end
@@ -0,0 +1,31 @@
1
+ # -*- encoding: utf-8 -*-
2
+ $:.push File.expand_path("../lib", __FILE__)
3
+ require "edge-state-machine/version"
4
+
5
+ Gem::Specification.new do |s|
6
+ s.name = "edge-state-machine"
7
+ s.version = EdgeStateMachine::VERSION
8
+ s.authors = ["Dan Persa"]
9
+ s.email = ["dan.persa@gmail.com"]
10
+ s.homepage = "http://github.com/danpersa/edge-state-machine"
11
+ s.summary = %q{State machine extracted from ActiveModel}
12
+ s.description = %q{Lightweight state machine extracted from ActiveModel}
13
+
14
+ s.rubyforge_project = "edge-state-machine"
15
+
16
+ s.files = `git ls-files`.split("\n")
17
+ s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
18
+ s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
19
+ s.require_paths = ["lib"]
20
+
21
+
22
+ # specify any dependencies here; for example:
23
+ s.add_development_dependency 'rspec', '~> 2.6'
24
+ s.add_development_dependency 'rake'
25
+ s.add_development_dependency 'mongoid'
26
+ s.add_development_dependency 'bson_ext'
27
+ s.add_development_dependency 'sqlite3-ruby'
28
+ s.add_development_dependency 'activerecord'
29
+
30
+ # s.add_runtime_dependency "rest-client"
31
+ end
@@ -0,0 +1,34 @@
1
+ module ActiveRecord
2
+ module EdgeStateMachine
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ include ::EdgeStateMachine
7
+ after_initialize :set_initial_state
8
+ validates_presence_of :state
9
+ validate :state_inclusion
10
+ end
11
+
12
+ protected
13
+
14
+ def write_state(state_machine, state)
15
+ self.state = state.to_s
16
+ save!
17
+ end
18
+
19
+ def read_state(state_machine)
20
+ self.state.to_sym
21
+ end
22
+
23
+ def set_initial_state
24
+ self.state ||= self.class.state_machine.initial_state.to_s
25
+ end
26
+
27
+ def state_inclusion
28
+ unless self.class.state_machine.states.map{|s| s.name.to_s }.include?(self.state.to_s)
29
+ self.errors.add(:state, :inclusion, :value => self.state)
30
+ end
31
+ end
32
+ end
33
+ end
34
+
@@ -0,0 +1,107 @@
1
+ module EdgeStateMachine
2
+ class Event
3
+ attr_reader :name, :success, :timestamp
4
+
5
+ def initialize(machine, name, options = {}, &block)
6
+ @machine, @name, @transitions = machine, name, []
7
+ if machine
8
+ machine.klass.send(:define_method, "#{name}!") do |*args|
9
+ machine.fire_event(name, self, true, *args)
10
+ end
11
+
12
+ machine.klass.send(:define_method, name.to_s) do |*args|
13
+ machine.fire_event(name, self, false, *args)
14
+ end
15
+ end
16
+ update(options, &block)
17
+ end
18
+
19
+ def fire(obj, to_state = nil, *args)
20
+ transitions = @transitions.select { |t| t.from == obj.current_state(@machine ? @machine.name : nil) }
21
+ raise InvalidTransition if transitions.size == 0
22
+
23
+ next_state = nil
24
+ transitions.each do |transition|
25
+ next if to_state && !Array(transition.to).include?(to_state)
26
+ if transition.perform(obj)
27
+ next_state = to_state || Array(transition.to).first
28
+ transition.execute(obj, *args)
29
+ break
30
+ end
31
+ end
32
+ # Update timestamps on obj if a timestamp has been defined
33
+ update_event_timestamp(obj, next_state) if timestamp_defined?
34
+ next_state
35
+ end
36
+
37
+ def transitions_from_state?(state)
38
+ @transitions.any? { |t| t.from? state }
39
+ end
40
+
41
+ def ==(event)
42
+ if event.is_a? Symbol
43
+ name == event
44
+ else
45
+ name == event.name
46
+ end
47
+ end
48
+
49
+ # Has the timestamp option been specified for this event?
50
+ def timestamp_defined?
51
+ !@timestamp.nil?
52
+ end
53
+
54
+ def update(options = {}, &block)
55
+ @success = options[:success] if options.key?(:success)
56
+ self.timestamp = options[:timestamp] if options[:timestamp]
57
+ instance_eval(&block) if block
58
+ self
59
+ end
60
+
61
+ # update the timestamp attribute on obj
62
+ def update_event_timestamp(obj, next_state)
63
+ obj.send "#{timestamp_attribute_name(obj, next_state)}=", Time.now
64
+ end
65
+
66
+ # Set the timestamp attribute.
67
+ # @raise [ArgumentError] timestamp should be either a String, Symbol or true
68
+ def timestamp=(value)
69
+ case value
70
+ when String, Symbol, TrueClass
71
+ @timestamp = value
72
+ else
73
+ raise ArgumentError, "timestamp must be either: true, a String or a Symbol"
74
+ end
75
+ end
76
+
77
+
78
+ private
79
+
80
+ # Returns the name of the timestamp attribute for this event
81
+ # If the timestamp was simply true it returns the default_timestamp_name
82
+ # otherwise, returns the user-specified timestamp name
83
+ def timestamp_attribute_name(obj, next_state)
84
+ timestamp == true ? default_timestamp_name(obj, next_state) : @timestamp
85
+ end
86
+
87
+ # If @timestamp is true, try a default timestamp name
88
+ def default_timestamp_name(obj, next_state)
89
+ at_name = "%s_at" % next_state
90
+ on_name = "%s_on" % next_state
91
+ case
92
+ when obj.respond_to?(at_name) then at_name
93
+ when obj.respond_to?(on_name) then on_name
94
+ else
95
+ raise NoMethodError, "Couldn't find a suitable timestamp field for event: #{@name}.
96
+ Please define #{at_name} or #{on_name} in #{obj.class}"
97
+ end
98
+ end
99
+
100
+
101
+ def transitions(trans_opts)
102
+ Array(trans_opts[:from]).each do |s|
103
+ @transitions << StateTransition.new(trans_opts.merge({:from => s.to_sym}))
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,85 @@
1
+ module EdgeStateMachine
2
+ class Machine
3
+ attr_writer :initial_state
4
+ attr_accessor :states, :events, :state_index
5
+ attr_reader :klass, :name, :auto_scopes
6
+
7
+ def initialize(klass, name, options = {}, &block)
8
+ @klass, @name, @states, @state_index, @events = klass, name, [], {}, {}
9
+ update(options, &block)
10
+ end
11
+
12
+ def initial_state
13
+ @initial_state ||= (states.first ? states.first.name : nil)
14
+ end
15
+
16
+ def update(options = {}, &block)
17
+ @initial_state = options[:initial] if options.key?(:initial)
18
+ @auto_scopes = options[:auto_scopes]
19
+ instance_eval(&block) if block
20
+ include_scopes if @auto_scopes && defined?(ActiveRecord::Base) && @klass < ActiveRecord::Base
21
+ self
22
+ end
23
+
24
+ def fire_event(event, record, persist, *args)
25
+ state_index[record.current_state(@name)].call_action(:exit, record)
26
+ if new_state = @events[event].fire(record, nil, *args)
27
+ state_index[new_state].call_action(:enter, record)
28
+
29
+ if record.respond_to?(event_fired_callback)
30
+ record.send(event_fired_callback, record.current_state, new_state, event)
31
+ end
32
+
33
+ record.current_state(@name, new_state, persist)
34
+ record.send(@events[event].success) if @events[event].success
35
+ true
36
+ else
37
+ if record.respond_to?(event_failed_callback)
38
+ record.send(event_failed_callback, event)
39
+ end
40
+
41
+ false
42
+ end
43
+ end
44
+
45
+ def states_for_select
46
+ states.map { |st| [st.display_name, st.name.to_s] }
47
+ end
48
+
49
+ def events_for(state)
50
+ events = @events.values.select { |event| event.transitions_from_state?(state) }
51
+ events.map! { |event| event.name }
52
+ end
53
+
54
+ def current_state_variable
55
+ "@#{@name}_current_state"
56
+ end
57
+
58
+ private
59
+
60
+ def state(name, options = {})
61
+ @states << (state_index[name] ||= State.new(name, :machine => self)).update(options)
62
+ end
63
+
64
+ def event(name, options = {}, &block)
65
+ (@events[name] ||= Event.new(self, name)).update(options, &block)
66
+ end
67
+
68
+ def event_fired_callback
69
+ @event_fired_callback ||= (@name == :default ? '' : "#{@name}_") + 'event_fired'
70
+ end
71
+
72
+ def event_failed_callback
73
+ @event_failed_callback ||= (@name == :default ? '' : "#{@name}_") + 'event_failed'
74
+ end
75
+
76
+ def include_scopes
77
+ @states.each do |state|
78
+ state_name = state.name.to_s
79
+ raise InvalidMethodOverride if @klass.respond_to?(state_name)
80
+ @klass.scope state_name, @klass.where(:state => state_name)
81
+ end
82
+ end
83
+ end
84
+ end
85
+
@@ -0,0 +1,45 @@
1
+ module EdgeStateMachine
2
+ class State
3
+ attr_reader :name, :options
4
+
5
+ def initialize(name, options = {})
6
+ @name = name
7
+ if machine = options.delete(:machine)
8
+ machine.klass.define_state_query_method(name)
9
+ end
10
+ update(options)
11
+ end
12
+
13
+ def ==(state)
14
+ if state.is_a? Symbol
15
+ name == state
16
+ else
17
+ name == state.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.gsub(/_/, ' ').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
+ end
45
+ end
@@ -0,0 +1,47 @@
1
+ module EdgeStateMachine
2
+ class StateTransition
3
+ attr_reader :from, :to, :options
4
+
5
+ def initialize(opts)
6
+ @from, @to, @guard, @on_transition = opts[:from], opts[:to], opts[:guard], opts[:on_transition]
7
+ @options = opts
8
+ end
9
+
10
+ def perform(obj)
11
+ case @guard
12
+ when Symbol, String
13
+ obj.send(@guard)
14
+ when Proc
15
+ @guard.call(obj)
16
+ else
17
+ true
18
+ end
19
+ end
20
+
21
+ def execute(obj, *args)
22
+ case @on_transition
23
+ when Symbol, String
24
+ obj.send(@on_transition, *args)
25
+ when Proc
26
+ @on_transition.call(obj, *args)
27
+ when Array
28
+ @on_transition.each do |callback|
29
+ # Yes, we're passing always the same parameters for each callback in here.
30
+ # We should probably drop args altogether in case we get an array.
31
+ obj.send(callback, *args)
32
+ end
33
+ else
34
+ # TODO We probably should check for this in the constructor and not that late.
35
+ raise ArgumentError, "You can only pass a Symbol, a String, a Proc or an Array to 'on_transition' - got #{@on_transition.class}." unless @on_transition.nil?
36
+ end
37
+ end
38
+
39
+ def ==(obj)
40
+ @from == obj.from && @to == obj.to
41
+ end
42
+
43
+ def from?(value)
44
+ @from == value
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,3 @@
1
+ module EdgeStateMachine
2
+ VERSION = "0.0.1"
3
+ end
@@ -0,0 +1,71 @@
1
+ require "edge-state-machine/event"
2
+ require "edge-state-machine/machine"
3
+ require "edge-state-machine/state"
4
+ require "edge-state-machine/state_transition"
5
+ require "edge-state-machine/version"
6
+
7
+ module EdgeStateMachine
8
+ class InvalidTransition < StandardError; end
9
+ class InvalidMethodOverride < StandardError; end
10
+
11
+ module ClassMethods
12
+ def inherited(klass)
13
+ super
14
+ klass.state_machines = state_machines
15
+ end
16
+
17
+ def state_machines
18
+ @state_machines ||= {}
19
+ end
20
+
21
+ def state_machines=(value)
22
+ @state_machines = value ? value.dup : nil
23
+ end
24
+
25
+ def state_machine(name = nil, options = {}, &block)
26
+ if name.is_a?(Hash)
27
+ options = name
28
+ name = nil
29
+ end
30
+ name ||= :default
31
+ state_machines[name] ||= Machine.new(self, name)
32
+ block ? state_machines[name].update(options, &block) : state_machines[name]
33
+ end
34
+
35
+ def define_state_query_method(state_name)
36
+ name = "#{state_name}?"
37
+ undef_method(name) if method_defined?(name)
38
+ class_eval "def #{name}; current_state.to_s == %(#{state_name}) end"
39
+ end
40
+ end
41
+
42
+ def self.included(base)
43
+ base.extend(ClassMethods)
44
+ end
45
+
46
+ def current_state(name = nil, new_state = nil, persist = false)
47
+ sm = self.class.state_machine(name)
48
+ ivar = sm.current_state_variable
49
+ if name && new_state
50
+ if persist && respond_to?(:write_state)
51
+ write_state(sm, new_state)
52
+ end
53
+
54
+ if respond_to?(:write_state_without_persistence)
55
+ write_state_without_persistence(sm, new_state)
56
+ end
57
+
58
+ instance_variable_set(ivar, new_state)
59
+ else
60
+ instance_variable_set(ivar, nil) unless instance_variable_defined?(ivar)
61
+ value = instance_variable_get(ivar)
62
+ return value if value
63
+
64
+ if respond_to?(:read_state)
65
+ value = instance_variable_set(ivar, read_state(sm))
66
+ end
67
+
68
+ value || sm.initial_state
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,34 @@
1
+ module Mongoid
2
+ module EdgeStateMachine
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ include ::EdgeStateMachine
7
+ after_initialize :set_initial_state
8
+ validates_presence_of :state
9
+ validate :state_inclusion
10
+ end
11
+
12
+ protected
13
+
14
+ def write_state(state_machine, state)
15
+ self.state = state.to_s
16
+ save!
17
+ end
18
+
19
+ def read_state(state_machine)
20
+ self.state.to_sym
21
+ end
22
+
23
+ def set_initial_state
24
+ self.state ||= self.class.state_machine.initial_state.to_s
25
+ end
26
+
27
+ def state_inclusion
28
+ unless self.class.state_machine.states.map{|s| s.name.to_s }.include?(self.state.to_s)
29
+ self.errors.add(:state, :inclusion, :value => self.state)
30
+ end
31
+ end
32
+ end
33
+ end
34
+
@@ -0,0 +1,10 @@
1
+ require 'spec_helper'
2
+ require 'active_record'
3
+ require 'active_support/core_ext/module/aliasing'
4
+ require 'migrations/create_orders'
5
+ require 'migrations/create_traffic_lights'
6
+
7
+ $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), "..", 'lib'))
8
+ $LOAD_PATH.unshift(File.dirname(__FILE__))
9
+
10
+ require 'active_record/edge-state-machine'