pluginaweek-state_machine 0.7.6

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 (78) hide show
  1. data/CHANGELOG.rdoc +273 -0
  2. data/LICENSE +20 -0
  3. data/README.rdoc +466 -0
  4. data/Rakefile +98 -0
  5. data/examples/AutoShop_state.png +0 -0
  6. data/examples/Car_state.png +0 -0
  7. data/examples/TrafficLight_state.png +0 -0
  8. data/examples/Vehicle_state.png +0 -0
  9. data/examples/auto_shop.rb +11 -0
  10. data/examples/car.rb +19 -0
  11. data/examples/merb-rest/controller.rb +51 -0
  12. data/examples/merb-rest/model.rb +28 -0
  13. data/examples/merb-rest/view_edit.html.erb +24 -0
  14. data/examples/merb-rest/view_index.html.erb +23 -0
  15. data/examples/merb-rest/view_new.html.erb +13 -0
  16. data/examples/merb-rest/view_show.html.erb +17 -0
  17. data/examples/rails-rest/controller.rb +43 -0
  18. data/examples/rails-rest/migration.rb +11 -0
  19. data/examples/rails-rest/model.rb +23 -0
  20. data/examples/rails-rest/view_edit.html.erb +25 -0
  21. data/examples/rails-rest/view_index.html.erb +23 -0
  22. data/examples/rails-rest/view_new.html.erb +14 -0
  23. data/examples/rails-rest/view_show.html.erb +17 -0
  24. data/examples/traffic_light.rb +7 -0
  25. data/examples/vehicle.rb +31 -0
  26. data/init.rb +1 -0
  27. data/lib/state_machine.rb +429 -0
  28. data/lib/state_machine/assertions.rb +36 -0
  29. data/lib/state_machine/callback.rb +189 -0
  30. data/lib/state_machine/condition_proxy.rb +94 -0
  31. data/lib/state_machine/eval_helpers.rb +67 -0
  32. data/lib/state_machine/event.rb +251 -0
  33. data/lib/state_machine/event_collection.rb +113 -0
  34. data/lib/state_machine/extensions.rb +158 -0
  35. data/lib/state_machine/guard.rb +219 -0
  36. data/lib/state_machine/integrations.rb +68 -0
  37. data/lib/state_machine/integrations/active_record.rb +444 -0
  38. data/lib/state_machine/integrations/active_record/locale.rb +10 -0
  39. data/lib/state_machine/integrations/active_record/observer.rb +41 -0
  40. data/lib/state_machine/integrations/data_mapper.rb +325 -0
  41. data/lib/state_machine/integrations/data_mapper/observer.rb +139 -0
  42. data/lib/state_machine/integrations/sequel.rb +292 -0
  43. data/lib/state_machine/machine.rb +1431 -0
  44. data/lib/state_machine/machine_collection.rb +146 -0
  45. data/lib/state_machine/matcher.rb +123 -0
  46. data/lib/state_machine/matcher_helpers.rb +54 -0
  47. data/lib/state_machine/node_collection.rb +152 -0
  48. data/lib/state_machine/state.rb +249 -0
  49. data/lib/state_machine/state_collection.rb +112 -0
  50. data/lib/state_machine/transition.rb +367 -0
  51. data/tasks/state_machine.rake +1 -0
  52. data/tasks/state_machine.rb +30 -0
  53. data/test/classes/switch.rb +11 -0
  54. data/test/functional/state_machine_test.rb +941 -0
  55. data/test/test_helper.rb +4 -0
  56. data/test/unit/assertions_test.rb +40 -0
  57. data/test/unit/callback_test.rb +455 -0
  58. data/test/unit/condition_proxy_test.rb +328 -0
  59. data/test/unit/eval_helpers_test.rb +129 -0
  60. data/test/unit/event_collection_test.rb +293 -0
  61. data/test/unit/event_test.rb +605 -0
  62. data/test/unit/guard_test.rb +862 -0
  63. data/test/unit/integrations/active_record_test.rb +1001 -0
  64. data/test/unit/integrations/data_mapper_test.rb +694 -0
  65. data/test/unit/integrations/sequel_test.rb +486 -0
  66. data/test/unit/integrations_test.rb +42 -0
  67. data/test/unit/invalid_event_test.rb +7 -0
  68. data/test/unit/invalid_transition_test.rb +7 -0
  69. data/test/unit/machine_collection_test.rb +710 -0
  70. data/test/unit/machine_test.rb +1910 -0
  71. data/test/unit/matcher_helpers_test.rb +37 -0
  72. data/test/unit/matcher_test.rb +155 -0
  73. data/test/unit/node_collection_test.rb +207 -0
  74. data/test/unit/state_collection_test.rb +280 -0
  75. data/test/unit/state_machine_test.rb +31 -0
  76. data/test/unit/state_test.rb +795 -0
  77. data/test/unit/transition_test.rb +1113 -0
  78. metadata +161 -0
@@ -0,0 +1,113 @@
1
+ module StateMachine
2
+ # Represents a collection of events in a state machine
3
+ class EventCollection < NodeCollection
4
+ def initialize(machine) #:nodoc:
5
+ super(machine, :index => [:name, :qualified_name])
6
+ end
7
+
8
+ # Gets the list of events that can be fired on the given object.
9
+ #
10
+ # == Examples
11
+ #
12
+ # class Vehicle
13
+ # state_machine :initial => :parked do
14
+ # event :park do
15
+ # transition :idling => :parked
16
+ # end
17
+ #
18
+ # event :ignite do
19
+ # transition :parked => :idling
20
+ # end
21
+ # end
22
+ # end
23
+ #
24
+ # events = Vehicle.state_machine(:state).events
25
+ #
26
+ # vehicle = Vehicle.new # => #<Vehicle:0xb7c464b0 @state="parked">
27
+ # events.valid_for(vehicle) # => [#<StateMachine::Event name=:ignite transitions=[:parked => :idling]>]
28
+ #
29
+ # vehicle.state = 'idling'
30
+ # events.valid_for(vehicle) # => [#<StateMachine::Event name=:park transitions=[:idling => :parked]>]
31
+ def valid_for(object)
32
+ select {|event| event.can_fire?(object)}
33
+ end
34
+
35
+ # Gets the list of transitions that can be run on the given object.
36
+ #
37
+ # == Examples
38
+ #
39
+ # class Vehicle
40
+ # state_machine :initial => :parked do
41
+ # event :park do
42
+ # transition :idling => :parked
43
+ # end
44
+ #
45
+ # event :ignite do
46
+ # transition :parked => :idling
47
+ # end
48
+ # end
49
+ # end
50
+ #
51
+ # events = Vehicle.state_machine.events
52
+ #
53
+ # vehicle = Vehicle.new # => #<Vehicle:0xb7c464b0 @state="parked">
54
+ # events.transitions_for(vehicle) # => [#<StateMachine::Transition attribute=:state event=:ignite from="parked" from_name=:parked to="idling" to_name=:idling>]
55
+ #
56
+ # vehicle.state = 'idling'
57
+ # events.transitions_for(vehicle) # => [#<StateMachine::Transition attribute=:state event=:park from="idling" from_name=:idling to="parked" to_name=:parked>]
58
+ def transitions_for(object)
59
+ map {|event| event.transition_for(object)}.compact
60
+ end
61
+
62
+ # Gets the transition that should be performed for the event stored in the
63
+ # given object's event attribute. This also takes an additional parameter
64
+ # for automatically invalidating the object if the event or transition
65
+ # are invalid. By default, this is turned off.
66
+ #
67
+ # *Note* that if a transition has already been generated for the event,
68
+ # then that transition will be used.
69
+ #
70
+ # == Examples
71
+ #
72
+ # class Vehicle < ActiveRecord::Base
73
+ # state_machine :initial => :parked do
74
+ # event :ignite do
75
+ # transition :parked => :idling
76
+ # end
77
+ # end
78
+ # end
79
+ #
80
+ # vehicle = Vehicle.new # => #<Vehicle id: nil, state: "parked">
81
+ # events = Vehicle.state_machine.events
82
+ #
83
+ # vehicle.state_event = nil
84
+ # events.attribute_transition_for(vehicle) # => nil # Event isn't defined
85
+ #
86
+ # vehicle.state_event = 'invalid'
87
+ # events.attribute_transition_for(vehicle) # => false # Event is invalid
88
+ #
89
+ # vehicle.state_event = 'ignite'
90
+ # events.attribute_transition_for(vehicle) # => #<StateMachine::Transition attribute=:state event=:ignite from="parked" from_name=:parked to="idling" to_name=:idling>
91
+ def attribute_transition_for(object, invalidate = false)
92
+ return unless machine.action
93
+
94
+ result = nil
95
+
96
+ if event_name = machine.read(object, :event)
97
+ if event = self[event_name.to_sym, :name]
98
+ unless result = machine.read(object, :event_transition) || event.transition_for(object)
99
+ # No valid transition: invalidate
100
+ machine.invalidate(object, :event, :invalid_event, [[:state, machine.states.match!(object).name]]) if invalidate
101
+ result = false
102
+ end
103
+ else
104
+ # Event is unknown: invalidate
105
+ machine.invalidate(object, :event, :invalid) if invalidate
106
+ result = false
107
+ end
108
+ end
109
+
110
+ result
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,158 @@
1
+ require 'state_machine/machine_collection'
2
+
3
+ module StateMachine
4
+ module ClassMethods
5
+ def self.extended(base) #:nodoc:
6
+ base.class_eval do
7
+ @state_machines = MachineCollection.new
8
+ end
9
+ end
10
+
11
+ # Gets the current list of state machines defined for this class. This
12
+ # class-level attribute acts like an inheritable attribute. The attribute
13
+ # is available to each subclass, each having a copy of its superclass's
14
+ # attribute.
15
+ #
16
+ # The hash of state machines maps <tt>:attribute</tt> => +machine+, e.g.
17
+ #
18
+ # Vehicle.state_machines # => {:state => #<StateMachine::Machine:0xb6f6e4a4 ...>}
19
+ def state_machines
20
+ @state_machines ||= superclass.state_machines.dup
21
+ end
22
+ end
23
+
24
+ module InstanceMethods
25
+ # Defines the initial values for state machine attributes. The values
26
+ # will be set *after* the original initialize method is invoked. This is
27
+ # necessary in order to ensure that the object is initialized before
28
+ # dynamic initial attributes are evaluated.
29
+ def initialize(*args, &block)
30
+ super
31
+ initialize_state_machines
32
+ end
33
+
34
+ # Runs one or more events in parallel. All events will run through the
35
+ # following steps:
36
+ # * Before callbacks
37
+ # * Persist state
38
+ # * Invoke action
39
+ # * After callbacks
40
+ #
41
+ # For example, if two events (for state machines A and B) are run in
42
+ # parallel, the order in which steps are run is:
43
+ # * A - Before transition callbacks
44
+ # * B - Before transition callbacks
45
+ # * A - Persist new state
46
+ # * B - Persist new state
47
+ # * A - Invoke action
48
+ # * B - Invoke action (only if different than A's action)
49
+ # * A - After transition callbacks
50
+ # * B - After transition callbacks
51
+ #
52
+ # *Note* that multiple events on the same state machine / attribute cannot
53
+ # be run in parallel. If this is attempted, an ArgumentError will be
54
+ # raised.
55
+ #
56
+ # == Halting callbacks
57
+ #
58
+ # When running multiple events in parallel, special consideration should
59
+ # be taken with regard to how halting within callbacks affects the flow.
60
+ #
61
+ # For *before* callbacks, any <tt>:halt</tt> error that's thrown will
62
+ # immediately cancel the perform for all transitions. As a result, it's
63
+ # possible for one event's transition to affect the continuation of
64
+ # another.
65
+ #
66
+ # On the other hand, any <tt>:halt</tt> error that's thrown within an
67
+ # *after* callback with only affect that event's transition. Other
68
+ # transitions will continue to run their own callbacks.
69
+ #
70
+ # == Example
71
+ #
72
+ # class Vehicle
73
+ # state_machine :initial => :parked do
74
+ # event :ignite do
75
+ # transition :parked => :idling
76
+ # end
77
+ #
78
+ # event :park do
79
+ # transition :idling => :parked
80
+ # end
81
+ # end
82
+ #
83
+ # state_machine :alarm_state, :namespace => 'alarm', :initial => :on do
84
+ # event :enable do
85
+ # transition all => :active
86
+ # end
87
+ #
88
+ # event :disable do
89
+ # transition all => :off
90
+ # end
91
+ # end
92
+ # end
93
+ #
94
+ # vehicle = Vehicle.new # => #<Vehicle:0xb7c02850 @state="parked", @alarm_state="active">
95
+ # vehicle.state # => "parked"
96
+ # vehicle.alarm_state # => "active"
97
+ #
98
+ # vehicle.fire_events(:ignite, :disable_alarm) # => true
99
+ # vehicle.state # => "idling"
100
+ # vehicle.alarm_state # => "off"
101
+ #
102
+ # # If any event fails, the entire event chain fails
103
+ # vehicle.fire_events(:ignite, :enable_alarm) # => false
104
+ # vehicle.state # => "idling"
105
+ # vehicle.alarm_state # => "off"
106
+ #
107
+ # # Exception raised on invalid event
108
+ # vehicle.fire_events(:park, :invalid) # => StateMachine::InvalidEvent: :invalid is an unknown event
109
+ # vehicle.state # => "idling"
110
+ # vehicle.alarm_state # => "off"
111
+ def fire_events(*events)
112
+ self.class.state_machines.fire_events(self, *events)
113
+ end
114
+
115
+ # Run one or more events in parallel. If any event fails to run, then
116
+ # a StateMachine::InvalidTransition exception will be raised.
117
+ #
118
+ # See StateMachine::InstanceMethods#fire_events for more information.
119
+ #
120
+ # == Example
121
+ #
122
+ # class Vehicle
123
+ # state_machine :initial => :parked do
124
+ # event :ignite do
125
+ # transition :parked => :idling
126
+ # end
127
+ #
128
+ # event :park do
129
+ # transition :idling => :parked
130
+ # end
131
+ # end
132
+ #
133
+ # state_machine :alarm_state, :namespace => 'alarm', :initial => :active do
134
+ # event :enable do
135
+ # transition all => :active
136
+ # end
137
+ #
138
+ # event :disable do
139
+ # transition all => :off
140
+ # end
141
+ # end
142
+ # end
143
+ #
144
+ # vehicle = Vehicle.new # => #<Vehicle:0xb7c02850 @state="parked", @alarm_state="active">
145
+ # vehicle.fire_events(:ignite, :disable_alarm) # => true
146
+ #
147
+ # vehicle.fire_events!(:ignite, :disable_alarm) # => StateMachine::InvalidTranstion: Cannot run events in parallel: ignite, disable_alarm
148
+ def fire_events!(*events)
149
+ run_action = [true, false].include?(events.last) ? events.pop : true
150
+ fire_events(*(events + [run_action])) || raise(StateMachine::InvalidTransition, "Cannot run events in parallel: #{events * ', '}")
151
+ end
152
+
153
+ protected
154
+ def initialize_state_machines #:nodoc:
155
+ self.class.state_machines.initialize_states(self)
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,219 @@
1
+ require 'state_machine/matcher'
2
+ require 'state_machine/eval_helpers'
3
+ require 'state_machine/assertions'
4
+
5
+ module StateMachine
6
+ # Represents a set of requirements that must be met in order for a transition
7
+ # or callback to occur. Guards verify that the event, from state, and to
8
+ # state of the transition match, in addition to if/unless conditionals for
9
+ # an object's state.
10
+ class Guard
11
+ include Assertions
12
+ include EvalHelpers
13
+
14
+ # The condition that must be met on an object
15
+ attr_reader :if_condition
16
+
17
+ # The condition that must *not* be met on an object
18
+ attr_reader :unless_condition
19
+
20
+ # The requirement for verifying the event being guarded
21
+ attr_reader :event_requirement
22
+
23
+ # One or more requirements for verifying the states being guarded. All
24
+ # requirements contain a mapping of {:from => matcher, :to => matcher}.
25
+ attr_reader :state_requirements
26
+
27
+ # A list of all of the states known to this guard. This will pull states
28
+ # from the following options (in the same order):
29
+ # * +from+ / +except_from+
30
+ # * +to+ / +except_to+
31
+ attr_reader :known_states
32
+
33
+ # Creates a new guard
34
+ def initialize(options = {}) #:nodoc:
35
+ # Build conditionals
36
+ @if_condition = options.delete(:if)
37
+ @unless_condition = options.delete(:unless)
38
+
39
+ # Build event requirement
40
+ @event_requirement = build_matcher(options, :on, :except_on)
41
+
42
+ if (options.keys - [:from, :to, :on, :except_from, :except_to, :except_on]).empty?
43
+ # Explicit from/to requirements specified
44
+ @state_requirements = [{:from => build_matcher(options, :from, :except_from), :to => build_matcher(options, :to, :except_to)}]
45
+ else
46
+ # Separate out the event requirement
47
+ options.delete(:on)
48
+ options.delete(:except_on)
49
+
50
+ # Implicit from/to requirements specified
51
+ @state_requirements = options.collect do |from, to|
52
+ from = WhitelistMatcher.new(from) unless from.is_a?(Matcher)
53
+ to = WhitelistMatcher.new(to) unless to.is_a?(Matcher)
54
+ {:from => from, :to => to}
55
+ end
56
+ end
57
+
58
+ # Track known states. The order that requirements are iterated is based
59
+ # on the priority in which tracked states should be added.
60
+ @known_states = []
61
+ @state_requirements.each do |state_requirement|
62
+ [:from, :to].each {|option| @known_states |= state_requirement[option].values}
63
+ end
64
+ end
65
+
66
+ # Determines whether the given object / query matches the requirements
67
+ # configured for this guard. In addition to matching the event, from state,
68
+ # and to state, this will also check whether the configured :if/:unless
69
+ # conditions pass on the given object.
70
+ #
71
+ # == Examples
72
+ #
73
+ # guard = StateMachine::Guard.new(:parked => :idling, :on => :ignite)
74
+ #
75
+ # # Successful
76
+ # guard.matches?(object, :on => :ignite) # => true
77
+ # guard.matches?(object, :from => nil) # => true
78
+ # guard.matches?(object, :from => :parked) # => true
79
+ # guard.matches?(object, :to => :idling) # => true
80
+ # guard.matches?(object, :from => :parked, :to => :idling) # => true
81
+ # guard.matches?(object, :on => :ignite, :from => :parked, :to => :idling) # => true
82
+ #
83
+ # # Unsuccessful
84
+ # guard.matches?(object, :on => :park) # => false
85
+ # guard.matches?(object, :from => :idling) # => false
86
+ # guard.matches?(object, :to => :first_gear) # => false
87
+ # guard.matches?(object, :from => :parked, :to => :first_gear) # => false
88
+ # guard.matches?(object, :on => :park, :from => :parked, :to => :idling) # => false
89
+ def matches?(object, query = {})
90
+ !match(object, query).nil?
91
+ end
92
+
93
+ # Attempts to match the given object / query against the set of requirements
94
+ # configured for this guard. In addition to matching the event, from state,
95
+ # and to state, this will also check whether the configured :if/:unless
96
+ # conditions pass on the given object.
97
+ #
98
+ # If a match is found, then the event/state requirements that the query
99
+ # passed successfully will be returned. Otherwise, nil is returned if there
100
+ # was no match.
101
+ #
102
+ # Query options:
103
+ # * <tt>:from</tt> - One or more states being transitioned from. If none
104
+ # are specified, then this will always match.
105
+ # * <tt>:to</tt> - One or more states being transitioned to. If none are
106
+ # specified, then this will always match.
107
+ # * <tt>:on</tt> - One or more events that fired the transition. If none
108
+ # are specified, then this will always match.
109
+ #
110
+ # == Examples
111
+ #
112
+ # guard = StateMachine::Guard.new(:parked => :idling, :on => :ignite)
113
+ #
114
+ # guard.match(object, :on => :ignite) # => {:to => ..., :from => ..., :on => ...}
115
+ # guard.match(object, :on => :park) # => nil
116
+ def match(object, query = {})
117
+ if (match = match_query(query)) && matches_conditions?(object)
118
+ match
119
+ end
120
+ end
121
+
122
+ # Draws a representation of this guard on the given graph. This will draw
123
+ # an edge between every state this guard matches *from* to either the
124
+ # configured to state or, if none specified, then a loopback to the from
125
+ # state.
126
+ #
127
+ # For example, if the following from states are configured:
128
+ # * +idling+
129
+ # * +first_gear+
130
+ # * +backing_up+
131
+ #
132
+ # ...and the to state is +parked+, then the following edges will be created:
133
+ # * +idling+ -> +parked+
134
+ # * +first_gear+ -> +parked+
135
+ # * +backing_up+ -> +parked+
136
+ #
137
+ # Each edge will be labeled with the name of the event that would cause the
138
+ # transition.
139
+ #
140
+ # The collection of edges generated on the graph will be returned.
141
+ def draw(graph, event, valid_states)
142
+ state_requirements.inject([]) do |edges, state_requirement|
143
+ # From states determined based on the known valid states
144
+ from_states = state_requirement[:from].filter(valid_states)
145
+
146
+ # If a to state is not specified, then it's a loopback and each from
147
+ # state maps back to itself
148
+ if state_requirement[:to].values.empty?
149
+ loopback = true
150
+ else
151
+ to_state = state_requirement[:to].values.first
152
+ to_state = to_state ? to_state.to_s : 'nil'
153
+ loopback = false
154
+ end
155
+
156
+ # Generate an edge between each from and to state
157
+ from_states.each do |from_state|
158
+ from_state = from_state ? from_state.to_s : 'nil'
159
+ edges << graph.add_edge(from_state, loopback ? from_state : to_state, :label => event.to_s)
160
+ end
161
+
162
+ edges
163
+ end
164
+ end
165
+
166
+ protected
167
+ # Builds a matcher strategy to use for the given options. If neither a
168
+ # whitelist nor a blacklist option is specified, then an AllMatcher is
169
+ # built.
170
+ def build_matcher(options, whitelist_option, blacklist_option)
171
+ assert_exclusive_keys(options, whitelist_option, blacklist_option)
172
+
173
+ if options.include?(whitelist_option)
174
+ WhitelistMatcher.new(options[whitelist_option])
175
+ elsif options.include?(blacklist_option)
176
+ BlacklistMatcher.new(options[blacklist_option])
177
+ else
178
+ AllMatcher.instance
179
+ end
180
+ end
181
+
182
+ # Verifies that all configured requirements (event and state) match the
183
+ # given query. If a match is found, then a hash containing the
184
+ # event/state requirements that passed will be returned; otherwise, nil.
185
+ def match_query(query)
186
+ query ||= {}
187
+
188
+ if match_event(query) && (state_requirement = match_states(query))
189
+ state_requirement.merge(:on => event_requirement)
190
+ end
191
+ end
192
+
193
+ # Verifies that the event requirement matches the given query
194
+ def match_event(query)
195
+ matches_requirement?(query, :on, event_requirement)
196
+ end
197
+
198
+ # Verifies that the state requirements match the given query. If a
199
+ # matching requirement is found, then it is returned.
200
+ def match_states(query)
201
+ state_requirements.detect do |state_requirement|
202
+ [:from, :to].all? {|option| matches_requirement?(query, option, state_requirement[option])}
203
+ end
204
+ end
205
+
206
+ # Verifies that an option in the given query matches the values required
207
+ # for that option
208
+ def matches_requirement?(query, option, requirement)
209
+ !query.include?(option) || requirement.matches?(query[option], query)
210
+ end
211
+
212
+ # Verifies that the conditionals for this guard evaluate to true for the
213
+ # given object
214
+ def matches_conditions?(object)
215
+ Array(if_condition).all? {|condition| evaluate_method(object, condition)} &&
216
+ !Array(unless_condition).any? {|condition| evaluate_method(object, condition)}
217
+ end
218
+ end
219
+ end