edge-state-machine 0.0.2 → 0.0.3

Sign up to get free protection for your applications and to get access to all the features.
data/README.rdoc CHANGED
@@ -1,14 +1,9 @@
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
1
  = Edge State Machine
6
2
 
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].
3
+ Edge state machine wants to be a complete state machine solution.
4
+ It offers support for ActiveRecord and Mongoid
10
5
 
11
- And on Krzysiek Heród's gem, {Transitions}[https://github.com/netizer/transitions], which added Mongoid support.
6
+ {<img src="https://secure.travis-ci.org/danpersa/edge-state-machine.png"/>}[http://travis-ci.org/danpersa/edge-state-machine]
12
7
 
13
8
  == Installation
14
9
 
@@ -60,4 +55,12 @@ If you're using Rails + Mongoid + Bundler
60
55
  event :available do
61
56
  transitions :to => :available, :from => [:out_of_stock], :on_transition => :send_alerts
62
57
  end
63
- end
58
+ end
59
+
60
+ = Credits
61
+
62
+ The gem is based on Rick Olson's code of ActiveModel::StateMachine,
63
+ axed from ActiveModel in {this
64
+ commit}[http://github.com/rails/rails/commit/db49c706b62e7ea2ab93f05399dbfddf5087ee0c].
65
+
66
+ And on Krzysiek Heród's gem, {Transitions}[https://github.com/netizer/transitions], which added Mongoid support.
@@ -5,28 +5,73 @@ module ActiveRecord
5
5
  included do
6
6
  include ::EdgeStateMachine
7
7
  after_initialize :set_initial_state
8
- validates_presence_of :state
8
+ validate :state_variables_presence
9
9
  validate :state_inclusion
10
10
  end
11
11
 
12
+ # The optional options argument is passed to find when reloading so you may
13
+ # do e.g. record.reload(:lock => true) to reload the same record with an
14
+ # exclusive row lock.
15
+ def reload(options = nil)
16
+ super.tap do
17
+ @current_states = {}
18
+ end
19
+ end
20
+
12
21
  protected
13
22
 
14
- def write_state(state_machine, state)
15
- self.state = state.to_s
16
- save!
23
+ def load_from_persistence(machine_name)
24
+ machine = self.class.state_machines[machine_name]
25
+ send machine.persisted_variable_name.to_s
17
26
  end
18
27
 
19
- def read_state(state_machine)
20
- self.state.to_sym
28
+ def save_to_persistence(new_state, machine_name, options = {})
29
+ machine = self.class.state_machines[machine_name]
30
+ send("#{machine.persisted_variable_name}=".to_sym, new_state)
31
+ save! if options[:save]
21
32
  end
22
33
 
23
34
  def set_initial_state
24
- self.state ||= self.class.state_machine.initial_state.to_s
35
+ # set the initial state for each state machine in this class
36
+ self.class.state_machines.keys.each do |machine_name|
37
+ machine = self.class.state_machines[machine_name]
38
+
39
+ if persisted_variable_value(machine.persisted_variable_name).blank?
40
+ if load_from_persistence(machine_name)
41
+ send("#{machine.persisted_variable_name}=".to_sym, load_from_persistence(machine_name))
42
+ else
43
+ send("#{machine.persisted_variable_name}=".to_sym, machine.initial_state_name)
44
+ end
45
+ end
46
+ end
47
+ end
48
+
49
+ def persisted_variable_value(name)
50
+ send(name.to_s)
51
+ end
52
+
53
+ def state_variables_presence
54
+ # validate that state is in the right set of values
55
+ self.class.state_machines.keys.each do |machine_name|
56
+ machine = self.class.state_machines[machine_name]
57
+ validates_presence_of machine.persisted_variable_name.to_sym
58
+ end
25
59
  end
26
60
 
27
61
  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)
62
+ # validate that state is in the right set of values
63
+ self.class.state_machines.keys.each do |machine_name|
64
+ machine = self.class.state_machines[machine_name]
65
+ unless machine.states.keys.include?(persisted_variable_value(machine.persisted_variable_name).to_sym)
66
+ self.errors.add(machine.persisted_variable_name.to_sym, :inclusion, :value => persisted_variable_value(machine.persisted_variable_name))
67
+ end
68
+ end
69
+ end
70
+
71
+ module ClassMethods
72
+ def add_scope(state, machine_name)
73
+ machine = state_machines[machine_name]
74
+ scope state.name, where(machine.persisted_variable_name.to_sym => state.name.to_s)
30
75
  end
31
76
  end
32
77
  end
@@ -2,106 +2,35 @@ module EdgeStateMachine
2
2
  class Event
3
3
  attr_reader :name, :success, :timestamp
4
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)
5
+ def initialize(name, machine, &transitions)
6
+ @machine = machine
7
+ @name = name
8
+ @transitions = []
9
+ instance_eval(&transitions) if block_given?
17
10
  end
18
11
 
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
12
+ def fire(obj, options = {})
13
+ current_state = obj.current_state(@machine.name)
14
+ transition = @transitions.select{ |t| t.from.include? current_state.name }.first
22
15
 
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
16
+ raise NoTransitionFound.new("No transition found for event #{@name}") if transition.nil?
17
+ return false unless transition.possible?(obj)
36
18
 
37
- def transitions_from_state?(state)
38
- @transitions.any? { |t| t.from? state }
39
- end
19
+ next_state = @machine.states[transition.find_next_state(obj)]
20
+ raise NoStateFound.new("Invalid state #{transition.to.to_s} for transition.") if next_state.nil?
21
+ transition.execute(obj)
40
22
 
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
23
+ current_state.execute_action(:exit, obj)
24
+ #klass._previous_state = current_state.name.to_s
25
+ next_state.execute_action(:enter, obj)
53
26
 
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
27
+ obj.set_current_state(next_state, @machine.name, options)
28
+ true
59
29
  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
30
 
78
31
  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 << Transition.new(trans_opts.merge({:from => s.to_sym}))
104
- end
32
+ def transition(trans_opts)
33
+ @transitions << EdgeStateMachine::Transition.new(trans_opts)
105
34
  end
106
35
  end
107
36
  end
@@ -0,0 +1,8 @@
1
+ module EdgeStateMachine
2
+ class InvalidTransition < StandardError; end
3
+ class InvalidMethodOverride < StandardError; end
4
+ class NoTransitionFound < Exception; end
5
+ class NoStateFound < Exception; end
6
+ class NoEventFound < Exception; end
7
+ class NoGuardFound < Exception; end
8
+ end
@@ -1,85 +1,40 @@
1
1
  module EdgeStateMachine
2
2
  class Machine
3
- attr_writer :initial_state
4
- attr_accessor :states, :events, :state_index
5
- attr_reader :klass, :name, :auto_scopes
3
+ attr_accessor :states, :events, :klass, :persisted_variable_name
4
+ attr_reader :name, :initial_state_name
6
5
 
7
- def initialize(klass, name, options = {}, &block)
8
- @klass, @name, @states, @state_index, @events = klass, name, [], {}, {}
9
- update(options, &block)
6
+ def initialize(klass, name, &block)
7
+ @klass = klass
8
+ @name = name
9
+ @states = Hash.new
10
+ @events = Hash.new
11
+ instance_eval(&block) if block_given?
10
12
  end
11
13
 
12
- def initial_state
13
- @initial_state ||= (states.first ? states.first.name : nil)
14
+ def initial_state(name)
15
+ @initial_state_name = name
14
16
  end
15
17
 
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
18
+ def persisted_to(name)
19
+ @persisted_variable_name = name
22
20
  end
23
21
 
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 }
22
+ def create_scopes(bool = false)
23
+ @create_scopes = bool
52
24
  end
53
25
 
54
- def current_state_variable
55
- "@#{@name}_current_state"
26
+ def create_scopes?
27
+ @create_scopes
56
28
  end
57
29
 
58
- private
59
-
60
- def state(name, options = {})
61
- @states << (state_index[name] ||= State.new(name, :machine => self)).update(options)
30
+ def state(name, &state)
31
+ state = EdgeStateMachine::State.new(name, &state)
32
+ @initial_state_name ||= state.name
33
+ @states[name.to_sym] = state
62
34
  end
63
35
 
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
36
+ def event(name, &transitions)
37
+ @events[name.to_sym] ||= EdgeStateMachine::Event.new(name, self, &transitions)
82
38
  end
83
39
  end
84
- end
85
-
40
+ end
@@ -2,44 +2,46 @@ module EdgeStateMachine
2
2
  class State
3
3
  attr_reader :name, :options
4
4
 
5
- def initialize(name, options = {})
5
+ def initialize(name, &block)
6
6
  @name = name
7
- if machine = options.delete(:machine)
8
- machine.klass.define_state_query_method(name)
9
- end
10
- update(options)
7
+ @options = Hash.new
8
+ instance_eval(&block) if block_given?
11
9
  end
12
10
 
13
- def ==(state)
14
- if state.is_a? Symbol
15
- name == state
16
- else
17
- name == state.name
18
- end
11
+ def enter(method = nil, &block)
12
+ @options[:enter] = method.nil? ? block : method
19
13
  end
20
14
 
21
- def call_action(action, record)
22
- action = @options[action]
15
+ def exit(method = nil, &block)
16
+ @options[:exit] = method.nil? ? block : method
17
+ end
18
+
19
+ def execute_action(action, base)
20
+ action = @options[action.to_sym]
23
21
  case action
24
22
  when Symbol, String
25
- record.send(action)
23
+ base.send(action)
26
24
  when Proc
27
- action.call(record)
25
+ action.call(base)
28
26
  end
29
27
  end
30
28
 
31
- def display_name
32
- @display_name ||= name.to_s.gsub(/_/, ' ').capitalize
29
+ def use_display_name(display_name)
30
+ @display_name = display_name
33
31
  end
34
32
 
35
- def for_select
36
- [display_name, name.to_s]
33
+ def display_name
34
+ @display_name ||= name.to_s.gsub(/_/, ' ').capitalize
37
35
  end
38
36
 
39
- def update(options = {})
40
- @display_name = options.delete(:display) if options.key?(:display)
41
- @options = options
42
- self
37
+ def ==(state)
38
+ if state.is_a? Symbol
39
+ name == state
40
+ elsif state.is_a? String
41
+ name == state
42
+ else
43
+ name == state.name
44
+ end
43
45
  end
44
46
  end
45
47
  end
@@ -3,36 +3,37 @@ module EdgeStateMachine
3
3
  attr_reader :from, :to, :options
4
4
 
5
5
  def initialize(opts)
6
- @from, @to, @guard, @on_transition = opts[:from], opts[:to], opts[:guard], opts[:on_transition]
7
- @options = opts
6
+ @from, @to, @guard, @on_transition = [opts[:from]].flatten, [opts[:to]].flatten, opts[:guard], [opts[:on_transition]].flatten
8
7
  end
9
8
 
10
- def perform(obj)
11
- case @guard
12
- when Symbol, String
13
- obj.send(@guard)
14
- when Proc
15
- @guard.call(obj)
9
+ def find_next_state(obj)
10
+ # if we have many states we can go but no guard
11
+ if @guard.nil? && @to.size > 1
12
+ raise NoGuardFound.new("There are many possible 'to' states but there is no 'guard' to decide which state to go")
13
+ end
14
+ if @guard
15
+ return execute_action(@guard, obj)
16
16
  else
17
- true
17
+ return @to.first
18
18
  end
19
19
  end
20
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)
21
+ def possible?(obj)
22
+ next_state = find_next_state(obj)
23
+ return true if @to.include? next_state
24
+ false
25
+ end
26
+
27
+ def execute(obj)
28
+ @on_transition.each do |transition|
29
+ case transition
30
+ when Symbol, String
31
+ obj.send(transition)
32
+ when Proc
33
+ transition.call(obj)
34
+ else
35
+ raise ArgumentError, "You can only pass a Symbol, a String or a Proc to 'on_transition' - got #{transition.class}." unless transition.nil?
32
36
  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
37
  end
37
38
  end
38
39
 
@@ -40,8 +41,14 @@ module EdgeStateMachine
40
41
  @from == obj.from && @to == obj.to
41
42
  end
42
43
 
43
- def from?(value)
44
- @from == value
44
+ private
45
+ def execute_action(action, base)
46
+ case action
47
+ when Symbol, String
48
+ base.send(action)
49
+ when Proc
50
+ action.call(base)
51
+ end
45
52
  end
46
53
  end
47
54
  end
@@ -1,3 +1,3 @@
1
1
  module EdgeStateMachine
2
- VERSION = "0.0.2"
2
+ VERSION = "0.0.3"
3
3
  end
@@ -2,11 +2,15 @@ require "edge-state-machine/event"
2
2
  require "edge-state-machine/machine"
3
3
  require "edge-state-machine/state"
4
4
  require "edge-state-machine/transition"
5
+ require "edge-state-machine/exception"
5
6
  require "edge-state-machine/version"
6
7
 
7
8
  module EdgeStateMachine
8
- class InvalidTransition < StandardError; end
9
- class InvalidMethodOverride < StandardError; end
9
+
10
+ def self.included(base)
11
+ base.extend(ClassMethods)
12
+ base.send :include, InstanceMethods
13
+ end
10
14
 
11
15
  module ClassMethods
12
16
  def inherited(klass)
@@ -22,52 +26,63 @@ module EdgeStateMachine
22
26
  @state_machines = value ? value.dup : nil
23
27
  end
24
28
 
25
- def state_machine(name = nil, options = {}, &block)
26
- if name.is_a?(Hash)
27
- options = name
28
- name = nil
29
+ def state_machine(name = :default, &block)
30
+ machine = Machine.new(self, name, &block)
31
+ state_machines[name] ||= machine
32
+
33
+ machine.persisted_variable_name ||= :state
34
+
35
+ machine.states.values.each do |state|
36
+ state_name = state.name
37
+ define_method "#{state_name}?" do
38
+ state_name == current_state(name).name
39
+ end
40
+ add_scope(state, name) if machine.create_scopes?
29
41
  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
42
 
35
- def define_state_query_method(state_name)
36
- name = "#{state_name}?"
37
- undef_method(name) if method_defined?(name)
38
- define_method(name) do
39
- current_state.to_s == state_name.to_s
43
+ machine.events.keys.each do |key|
44
+ define_method "#{key}" do
45
+ fire_event(machine.name, {:save => false}, key)
46
+ end
47
+
48
+ define_method "#{key}!" do
49
+ fire_event(machine.name, {:save => true}, key)
50
+ end
40
51
  end
41
52
  end
42
53
  end
43
54
 
44
- def self.included(base)
45
- base.extend(ClassMethods)
46
- end
55
+ module InstanceMethods
56
+ attr_writer :current_state
47
57
 
48
- def current_state(name = nil, new_state = nil, persist = false)
49
- sm = self.class.state_machine(name)
50
- ivar = sm.current_state_variable
51
- if name && new_state
52
- if persist && respond_to?(:write_state)
53
- write_state(sm, new_state)
54
- end
58
+ def initial_state_name(name = :default)
59
+ machine = self.class.state_machines[name]
60
+ return machine.initial_state_name
61
+ end
55
62
 
56
- if respond_to?(:write_state_without_persistence)
57
- write_state_without_persistence(sm, new_state)
63
+ def current_state(name = :default)
64
+ @current_states ||= {}
65
+ machine = self.class.state_machines[name]
66
+ if (respond_to? :load_from_persistence)
67
+ @current_states[name] ||= self.class.state_machines[name].states[load_from_persistence(name).to_sym]
58
68
  end
69
+ @current_states[name] ||= machine.states[machine.initial_state_name]
70
+ end
59
71
 
60
- instance_variable_set(ivar, new_state)
61
- else
62
- instance_variable_set(ivar, nil) unless instance_variable_defined?(ivar)
63
- value = instance_variable_get(ivar)
64
- return value if value
72
+ def current_state_name(name = :default)
73
+ current_state(name).name
74
+ end
65
75
 
66
- if respond_to?(:read_state)
67
- value = instance_variable_set(ivar, read_state(sm))
68
- end
76
+ def fire_event(name = :default, options = {}, event_name)
77
+ machine = self.class.state_machines[name]
78
+ event = machine.events[event_name]
79
+ raise Stateflow::NoEventFound.new("No event matches #{event_name}") if event.nil?
80
+ event.fire(self, options)
81
+ end
69
82
 
70
- value || sm.initial_state
83
+ def set_current_state(new_state, machine_name = :default, options = {})
84
+ save_to_persistence(new_state.name.to_s, machine_name, options) if self.respond_to? :save_to_persistence
85
+ @current_states[machine_name] = new_state
71
86
  end
72
87
  end
73
88
  end