edge-state-machine 0.0.2 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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