state_machine 0.3.1 → 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
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