state_machine 0.3.1 → 0.4.0

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.
Files changed (52) hide show
  1. data/CHANGELOG.rdoc +26 -0
  2. data/README.rdoc +254 -46
  3. data/Rakefile +29 -3
  4. data/examples/AutoShop_state.png +0 -0
  5. data/examples/Car_state.jpg +0 -0
  6. data/examples/Vehicle_state.png +0 -0
  7. data/lib/state_machine.rb +161 -116
  8. data/lib/state_machine/assertions.rb +21 -0
  9. data/lib/state_machine/callback.rb +168 -0
  10. data/lib/state_machine/eval_helpers.rb +67 -0
  11. data/lib/state_machine/event.rb +135 -101
  12. data/lib/state_machine/extensions.rb +83 -0
  13. data/lib/state_machine/guard.rb +115 -0
  14. data/lib/state_machine/integrations/active_record.rb +242 -0
  15. data/lib/state_machine/integrations/data_mapper.rb +198 -0
  16. data/lib/state_machine/integrations/data_mapper/observer.rb +153 -0
  17. data/lib/state_machine/integrations/sequel.rb +169 -0
  18. data/lib/state_machine/machine.rb +746 -352
  19. data/lib/state_machine/transition.rb +104 -212
  20. data/test/active_record.log +34865 -0
  21. data/test/classes/switch.rb +11 -0
  22. data/test/data_mapper.log +14015 -0
  23. data/test/functional/state_machine_test.rb +249 -15
  24. data/test/sequel.log +3835 -0
  25. data/test/test_helper.rb +3 -12
  26. data/test/unit/assertions_test.rb +13 -0
  27. data/test/unit/callback_test.rb +189 -0
  28. data/test/unit/eval_helpers_test.rb +92 -0
  29. data/test/unit/event_test.rb +247 -113
  30. data/test/unit/guard_test.rb +420 -0
  31. data/test/unit/integrations/active_record_test.rb +515 -0
  32. data/test/unit/integrations/data_mapper_test.rb +407 -0
  33. data/test/unit/integrations/sequel_test.rb +244 -0
  34. data/test/unit/invalid_transition_test.rb +1 -1
  35. data/test/unit/machine_test.rb +1056 -98
  36. data/test/unit/state_machine_test.rb +14 -113
  37. data/test/unit/transition_test.rb +269 -495
  38. metadata +44 -30
  39. data/test/app_root/app/models/auto_shop.rb +0 -34
  40. data/test/app_root/app/models/car.rb +0 -19
  41. data/test/app_root/app/models/highway.rb +0 -3
  42. data/test/app_root/app/models/motorcycle.rb +0 -3
  43. data/test/app_root/app/models/switch.rb +0 -23
  44. data/test/app_root/app/models/switch_observer.rb +0 -20
  45. data/test/app_root/app/models/toggle_switch.rb +0 -2
  46. data/test/app_root/app/models/vehicle.rb +0 -78
  47. data/test/app_root/config/environment.rb +0 -7
  48. data/test/app_root/db/migrate/001_create_switches.rb +0 -12
  49. data/test/app_root/db/migrate/002_create_auto_shops.rb +0 -13
  50. data/test/app_root/db/migrate/003_create_highways.rb +0 -11
  51. data/test/app_root/db/migrate/004_create_vehicles.rb +0 -16
  52. data/test/factory.rb +0 -77
@@ -0,0 +1,67 @@
1
+ module StateMachine
2
+ # Provides a set of helper methods for evaluating methods within the context
3
+ # of an object.
4
+ module EvalHelpers
5
+ # Evaluates one of several different types of methods within the context
6
+ # of the given object. Methods can be one of the following types:
7
+ # * Symbol
8
+ # * Method / Proc
9
+ # * String
10
+ #
11
+ # == Examples
12
+ #
13
+ # Below are examples of the various ways that a method can be evaluated
14
+ # on an object:
15
+ #
16
+ # class Person
17
+ # def initialize(name)
18
+ # @name = name
19
+ # end
20
+ #
21
+ # def name
22
+ # @name
23
+ # end
24
+ # end
25
+ #
26
+ # class PersonCallback
27
+ # def self.run(person)
28
+ # person.name
29
+ # end
30
+ # end
31
+ #
32
+ # person = Person.new('John Smith')
33
+ #
34
+ # evaluate_method(person, :name) # => "John Smith"
35
+ # evaluate_method(person, PersonCallback.method(:run)) # => "John Smith"
36
+ # evaluate_method(person, Proc.new {|person| person.name}) # => "John Smith"
37
+ # evaluate_method(person, lambda {|person| person.name}) # => "John Smith"
38
+ # evaluate_method(person, '@name') # => "John Smith"
39
+ #
40
+ # == Additional arguments
41
+ #
42
+ # Additional arguments can be passed to the methods being evaluated. If
43
+ # the method defines additional arguments other than the object context,
44
+ # then all arguments are required.
45
+ #
46
+ # For example,
47
+ #
48
+ # person = Person.new('John Smith')
49
+ #
50
+ # evaluate_method(person, lambda {|person| person.name}, 21) # => "John Smith"
51
+ # evaluate_method(person, lambda {|person, age| "#{person.name} is #{age}"}, 21) # => "John Smith is 21"
52
+ # evaluate_method(person, lambda {|person, age| "#{person.name} is #{age}"}, 21, 'male') # => ArgumentError: wrong number of arguments (3 for 2)
53
+ def evaluate_method(object, method, *args)
54
+ case method
55
+ when Symbol
56
+ method = object.method(method)
57
+ method.arity == 0 ? method.call : method.call(*args)
58
+ when String
59
+ eval(method, object.instance_eval {binding})
60
+ when Proc, Method
61
+ method.arity == 1 ? method.call(object) : method.call(object, *args)
62
+ else
63
+ raise ArgumentError, 'Methods must be a symbol denoting the method to call, a string to be evaluated, or a block to be invoked'
64
+ end
65
+ end
66
+ end
67
+ end
@@ -1,116 +1,150 @@
1
1
  require 'state_machine/transition'
2
+ require 'state_machine/guard'
3
+ require 'state_machine/assertions'
2
4
 
3
- module PluginAWeek #:nodoc:
4
- module StateMachine
5
- # An event defines an action that transitions an attribute from one state to
6
- # another
7
- class Event
8
- # The state machine for which this event is defined
9
- attr_accessor :machine
5
+ module StateMachine
6
+ # An event defines an action that transitions an attribute from one state to
7
+ # another. The state that an attribute is transitioned to depends on the
8
+ # guards configured for the event.
9
+ class Event
10
+ include Assertions
11
+
12
+ # The state machine for which this event is defined
13
+ attr_accessor :machine
14
+
15
+ # The name of the action that fires the event
16
+ attr_reader :name
17
+
18
+ # The list of guards that determine what state this event transitions
19
+ # objects to when fired
20
+ attr_reader :guards
21
+
22
+ # A list of all of the states known to this event using the configured
23
+ # guards/transitions as the source.
24
+ attr_reader :known_states
25
+
26
+ # Creates a new event within the context of the given machine
27
+ def initialize(machine, name) #:nodoc:
28
+ @machine = machine
29
+ @name = name
30
+ @guards = []
31
+ @known_states = []
10
32
 
11
- # The name of the action that fires the event
12
- attr_reader :name
13
-
14
- # The list of transitions that can be made for this event
15
- attr_reader :transitions
16
-
17
- delegate :owner_class,
18
- :to => :machine
19
-
20
- # Creates a new event within the context of the given machine
21
- def initialize(machine, name)
22
- @machine = machine
23
- @name = name
24
- @transitions = []
25
-
26
- add_actions
27
- end
28
-
29
- # Creates a copy of this event in addition to the list of associated
30
- # transitions to prevent conflicts across different events.
31
- def initialize_copy(orig) #:nodoc:
32
- super
33
- @transitions = @transitions.dup
34
- end
35
-
36
- # Creates a new transition to the specified state.
37
- #
38
- # Configuration options:
39
- # * +to+ - The state that being transitioned to
40
- # * +from+ - A state or array of states that can be transitioned from. If not specified, then the transition can occur for *any* from state
41
- # * +except_from+ - A state or array of states that *cannot* be transitioned from.
42
- # * +if+ - Specifies a method, proc or string to call to determine if the transition should occur (e.g. :if => :moving?, or :if => Proc.new {|car| car.speed > 60}). The method, proc or string should return or evaluate to a true or false value.
43
- # * +unless+ - Specifies a method, proc or string to call to determine if the transition should not occur (e.g. :unless => :stopped?, or :unless => Proc.new {|car| car.speed <= 60}). The method, proc or string should return or evaluate to a true or false value.
44
- #
45
- # == Examples
46
- #
47
- # transition :to => 'parked'
48
- # transition :to => 'parked', :from => 'first_gear'
49
- # transition :to => 'parked', :from => %w(first_gear reverse)
50
- # transition :to => 'parked', :from => 'first_gear', :if => :moving?
51
- # transition :to => 'parked', :from => 'first_gear', :unless => :stopped?
52
- # transition :to => 'parked', :except_from => 'parked'
53
- def transition(options)
54
- transitions << transition = Transition.new(self, options)
55
- transition
56
- end
33
+ add_actions
34
+ end
35
+
36
+ # Creates a copy of this event in addition to the list of associated
37
+ # guards to prevent conflicts across different events.
38
+ def initialize_copy(orig) #:nodoc:
39
+ super
40
+ @guards = @guards.dup
41
+ @known_states = @known_states.dup
42
+ end
43
+
44
+ # Creates a new transition that will be evaluated when the event is fired.
45
+ #
46
+ # Configuration options:
47
+ # * +to+ - The state that being transitioned to. If not specified, then the transition will not change the state.
48
+ # * +from+ - A state or array of states that can be transitioned from. If not specified, then the transition can occur for *any* from state
49
+ # * +except_from+ - A state or array of states that *cannot* be transitioned from.
50
+ # * +if+ - Specifies a method, proc or string to call to determine if the transition should occur (e.g. :if => :moving?, or :if => Proc.new {|car| car.speed > 60}). The method, proc or string should return or evaluate to a true or false value.
51
+ # * +unless+ - Specifies a method, proc or string to call to determine if the transition should not occur (e.g. :unless => :stopped?, or :unless => Proc.new {|car| car.speed <= 60}). The method, proc or string should return or evaluate to a true or false value.
52
+ #
53
+ # == Order of operations
54
+ #
55
+ # Transitions are evaluated in the order in which they're defined. As a
56
+ # result, if more than one transition applies to a given object, then the
57
+ # first transition that matches will be performed.
58
+ #
59
+ # == Dynamic states
60
+ #
61
+ # There is limited support for using dynamically generated values for the
62
+ # +to+ state in transitions. This is especially useful for times where
63
+ # the machine attribute represents a Time object. In order to have a
64
+ # a transition be made to the current time, a lambda block can be passed
65
+ # in representing the state, such as:
66
+ #
67
+ # transition :to => lambda {Time.now}
68
+ #
69
+ # == Examples
70
+ #
71
+ # transition :from => %w(first_gear reverse)
72
+ # transition :except_from => 'parked'
73
+ # transition :to => 'parked'
74
+ # transition :to => lambda {Time.now}
75
+ # transition :to => 'parked', :from => 'first_gear'
76
+ # transition :to => 'parked', :from => %w(first_gear reverse)
77
+ # transition :to => 'parked', :from => 'first_gear', :if => :moving?
78
+ # transition :to => 'parked', :from => 'first_gear', :unless => :stopped?
79
+ # transition :to => 'parked', :except_from => 'parked'
80
+ def transition(options)
81
+ assert_valid_keys(options, :to, :from, :except_from, :if, :unless)
57
82
 
58
- # Determines whether any transitions can be performed for this event based
59
- # on the current state of the given record.
60
- #
61
- # If the event can't be fired, then this will return false, otherwise true.
62
- def can_fire?(record)
63
- transitions.any? {|transition| transition.can_perform?(record)}
64
- end
83
+ guards << guard = Guard.new(options)
84
+ @known_states |= guard.known_states
85
+ guard
86
+ end
87
+
88
+ # Determines whether any transitions can be performed for this event based
89
+ # on the current state of the given object.
90
+ #
91
+ # If the event can't be fired, then this will return false, otherwise true.
92
+ def can_fire?(object)
93
+ !next_transition(object).nil?
94
+ end
95
+
96
+ # Finds and builds the next transition that can be performed on the given
97
+ # object. If no transitions can be made, then this will return nil.
98
+ def next_transition(object)
99
+ from = object.send(machine.attribute)
65
100
 
66
- # Attempts to perform one of the event's transitions for the given record.
67
- # Any additional arguments will be passed to the event's callbacks.
68
- def fire(record)
69
- run(record, false) || false
101
+ if guard = guards.find {|guard| guard.matches?(object, :from => from)}
102
+ # Guard allows for the transition to occur
103
+ to = guard.requirements[:to] || from
104
+ to = to.call if to.is_a?(Proc)
105
+ Transition.new(object, machine, name, from, to)
70
106
  end
71
-
72
- # Attempts to perform one of the event's transitions for the given record.
73
- # If the transition cannot be made, then a PluginAWeek::StateMachine::InvalidTransition
74
- # error will be raised.
75
- def fire!(record)
76
- run(record, true) || raise(PluginAWeek::StateMachine::InvalidTransition, "Cannot transition via :#{name} from \"#{record.send(machine.attribute)}\"")
107
+ end
108
+
109
+ # Attempts to perform the next available transition on the given object.
110
+ # If no transitions can be made, then this will return false, otherwise
111
+ # true.
112
+ def fire(object, *args)
113
+ if transition = next_transition(object)
114
+ transition.perform(*args)
115
+ else
116
+ false
77
117
  end
78
-
79
- private
80
- # Add the various instance methods that can transition the record using
81
- # the current event
82
- def add_actions
83
- attribute = machine.attribute
84
- name = self.name
118
+ end
119
+
120
+ protected
121
+ # Add the various instance methods that can transition the object using
122
+ # the current event
123
+ def add_actions
124
+ attribute = machine.attribute
125
+ name = self.name
126
+
127
+ machine.owner_class.class_eval do
128
+ # Checks whether the event can be fired on the current object
129
+ define_method("can_#{name}?") do
130
+ self.class.state_machines[attribute].events[name].can_fire?(self)
131
+ end
85
132
 
86
- owner_class.class_eval do
87
- define_method(name) {self.class.state_machines[attribute].events[name].fire(self)}
88
- define_method("#{name}!") {self.class.state_machines[attribute].events[name].fire!(self)}
89
- define_method("can_#{name}?") {self.class.state_machines[attribute].events[name].can_fire?(self)}
133
+ # Gets the next transition that would be performed if the event were to be fired now
134
+ define_method("next_#{name}_transition") do
135
+ self.class.state_machines[attribute].events[name].next_transition(self)
90
136
  end
91
- end
92
-
93
- # Attempts to find a transition that can be performed for this event.
94
- #
95
- # +bang+ indicates whether +perform+ or <tt>perform!</tt> will be
96
- # invoked on transitions.
97
- def run(record, bang)
98
- result = false
99
137
 
100
- record.class.transaction do
101
- transitions.each do |transition|
102
- if transition.can_perform?(record)
103
- result = bang ? transition.perform!(record) : transition.perform(record)
104
- break
105
- end
106
- end
107
-
108
- # Rollback any changes if the transition failed
109
- raise ActiveRecord::Rollback unless result
138
+ # Fires the event
139
+ define_method(name) do |*args|
140
+ self.class.state_machines[attribute].events[name].fire(self, *args)
110
141
  end
111
142
 
112
- result
143
+ # Fires the event, raising an exception if it fails to transition
144
+ define_method("#{name}!") do |*args|
145
+ send(name, *args) || raise(StateMachine::InvalidTransition, "Cannot transition via :#{name} from #{send(attribute).inspect}")
146
+ end
113
147
  end
114
- end
148
+ end
115
149
  end
116
150
  end
@@ -0,0 +1,83 @@
1
+ module StateMachine
2
+ module ClassMethods
3
+ def self.extended(base) #:nodoc:
4
+ base.class_eval do
5
+ @state_machines = {}
6
+
7
+ # method_added may get defined by the class, so instead it's chained
8
+ class << self
9
+ alias_method :method_added_without_state_machine, :method_added
10
+ alias_method :method_added, :method_added_with_state_machine
11
+ end
12
+ end
13
+ end
14
+
15
+ # Ensures that the +initialize+ hook defined in StateMachine::InstanceMethods
16
+ # remains there even if the class defines its own +initialize+ method
17
+ # *after* the state machine has been defined. For example,
18
+ #
19
+ # class Switch
20
+ # state_machine do
21
+ # ...
22
+ # end
23
+ #
24
+ # def initialize(attributes = {})
25
+ # ...
26
+ # end
27
+ # end
28
+ def method_added_with_state_machine(method) #:nodoc:
29
+ method_added_without_state_machine(method)
30
+
31
+ # Aliasing the +initialize+ method also invokes +method_added+, so
32
+ # alias processing is tracked to prevent an infinite loop
33
+ if !@skip_initialize_hook && [:initialize, :initialize_with_state_machine].include?(method)
34
+ @skip_initialize_hook = true
35
+
36
+ # +define_method+ is used to prevent it from showing up in #instance_methods
37
+ alias_method :initialize_without_state_machine, :initialize
38
+ class_eval <<-end_eval, __FILE__, __LINE__
39
+ def initialize(*args, &block)
40
+ initialize_with_state_machine(*args, &block)
41
+ end
42
+ end_eval
43
+
44
+ @skip_initialize_hook = false
45
+ end
46
+ end
47
+
48
+ # Gets the current list of state machines defined for this class. This
49
+ # class-level attribute acts like an inheritable attribute. The attribute
50
+ # is available to each subclass, each subclass having a copy of its
51
+ # superclass's attribute.
52
+ #
53
+ # The hash of state machines maps +name+ => +machine+, e.g.
54
+ #
55
+ # Vehicle.state_machines # => {"state" => #<StateMachine::Machine:0xb6f6e4a4 ...>
56
+ def state_machines
57
+ @state_machines ||= superclass.state_machines.dup
58
+ end
59
+ end
60
+
61
+ module InstanceMethods
62
+ def self.included(base) #:nodoc:
63
+ # Methods added from an included module don't invoke +method_added+,
64
+ # triggering the initialize alias, so it's done explicitly
65
+ base.method_added(:initialize_with_state_machine)
66
+ end
67
+
68
+ # Defines the initial values for state machine attributes. The values
69
+ # will be set *after* the original initialize method is invoked. This is
70
+ # necessary in order to ensure that the object is initialized before
71
+ # dynamic initial attributes are evaluated.
72
+ def initialize_with_state_machine(*args, &block)
73
+ initialize_without_state_machine(*args, &block)
74
+
75
+ self.class.state_machines.each do |attribute, machine|
76
+ # Set the initial value of the machine's attribute unless it already
77
+ # exists (which must mean the defaults are being skipped)
78
+ value = send(attribute)
79
+ send("#{attribute}=", machine.initial_state(self)) if value.nil? || value.respond_to?(:empty?) && value.empty?
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,115 @@
1
+ require 'state_machine/eval_helpers'
2
+ require 'state_machine/assertions'
3
+
4
+ module StateMachine
5
+ # Represents a set of requirements that must be met in order for a transition
6
+ # or callback to occur. Guards verify that the event, from state, and to
7
+ # state of the transition match, in addition to if/unless conditionals for
8
+ # an object's state.
9
+ class Guard
10
+ include Assertions
11
+ include EvalHelpers
12
+
13
+ # The transition/conditional options that must be met in order for the
14
+ # guard to match
15
+ attr_reader :requirements
16
+
17
+ # A list of all of the states known to this guard. This will pull state
18
+ # names from the following requirements:
19
+ # * +to+
20
+ # * +from+
21
+ # * +except_to+
22
+ # * +except_from+
23
+ attr_reader :known_states
24
+
25
+ # Creates a new guard with the given requirements
26
+ def initialize(requirements = {}) #:nodoc:
27
+ assert_valid_keys(requirements, :to, :from, :on, :except_to, :except_from, :except_on, :if, :unless)
28
+
29
+ @requirements = requirements
30
+ @known_states = [:to, :from, :except_to, :except_from].inject([]) {|states, option| states |= Array(requirements[option])}
31
+ end
32
+
33
+ # Determines whether the given object / query matches the requirements
34
+ # configured for this guard. In addition to matching the event, from state,
35
+ # and to state, this will also check whether the configured :if/:unless
36
+ # conditionals pass on the given object.
37
+ #
38
+ # Query options:
39
+ # * +to+ - One or more states being transitioned to. If none are specified, then this will always match.
40
+ # * +from+ - One or more states being transitioned from. If none are specified, then this will always match.
41
+ # * +on+ - One or more events that fired the transition. If none are specified, then this will always match.
42
+ # * +except_to+ - One more states *not* being transitioned to
43
+ # * +except_from+ - One or more states *not* being transitioned from
44
+ # * +except_on+ - One or more events that *did not* fire the transition.
45
+ #
46
+ # == Examples
47
+ #
48
+ # guard = StateMachine::Guard.new(:on => 'ignite', :from => 'parked', :to => 'idling')
49
+ #
50
+ # # Successful
51
+ # guard.matches?(object, :on => 'ignite') # => true
52
+ # guard.matches?(object, :from => 'parked') # => true
53
+ # guard.matches?(object, :to => 'idling') # => true
54
+ # guard.matches?(object, :from => 'parked', :to => 'idling') # => true
55
+ # guard.matches?(object, :on => 'ignite', :from => 'parked', :to => 'idling') # => true
56
+ #
57
+ # # Unsuccessful
58
+ # guard.matches?(object, :on => 'park') # => false
59
+ # guard.matches?(object, :from => 'idling') # => false
60
+ # guard.matches?(object, :to => 'first_gear') # => false
61
+ # guard.matches?(object, :from => 'parked', :to => 'first_gear') # => false
62
+ # guard.matches?(object, :on => 'park', :from => 'parked', :to => 'idling') # => false
63
+ def matches?(object, query = {})
64
+ matches_query?(object, query) && matches_conditions?(object)
65
+ end
66
+
67
+ protected
68
+ # Verify that the from state, to state, and event match the query
69
+ def matches_query?(object, query)
70
+ (!query || query.empty?) ||
71
+ find_match(query[:from], requirements[:from], requirements[:except_from]) &&
72
+ find_match(query[:to], requirements[:to], requirements[:except_to]) &&
73
+ find_match(query[:on], requirements[:on], requirements[:except_on])
74
+ end
75
+
76
+ # Verify that the conditionals for this guard evaluate to true for the
77
+ # given object
78
+ def matches_conditions?(object)
79
+ if requirements[:if]
80
+ evaluate_method(object, requirements[:if])
81
+ elsif requirements[:unless]
82
+ !evaluate_method(object, requirements[:unless])
83
+ else
84
+ true
85
+ end
86
+ end
87
+
88
+ # Attempts to find the given value in either a whitelist of values or
89
+ # a blacklist of values. The whitelist will always be used first if it
90
+ # is specified. If neither lists are specified or the value is blank,
91
+ # then this will always find a match and return true.
92
+ #
93
+ # == Examples
94
+ #
95
+ # find_match(nil, %w(parked idling), nil) # => true
96
+ # find_match('parked', nil, nil) # => true
97
+ # find_match('parked', %w(parked idling), nil) # => true
98
+ # find_match('first_gear', %w(parked idling, nil) # => false
99
+ # find_match('parked', nil, %w(parked idling)) # => false
100
+ # find_match('first_gear', nil, %w(parked idling)) # => true
101
+ def find_match(value, whitelist, blacklist)
102
+ if value
103
+ if whitelist
104
+ Array(whitelist).include?(value)
105
+ elsif blacklist
106
+ !Array(blacklist).include?(value)
107
+ else
108
+ true
109
+ end
110
+ else
111
+ true
112
+ end
113
+ end
114
+ end
115
+ end