verborghs-state_machine 0.9.4

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 (89) hide show
  1. data/CHANGELOG.rdoc +360 -0
  2. data/LICENSE +20 -0
  3. data/README.rdoc +635 -0
  4. data/Rakefile +77 -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/assertions.rb +36 -0
  28. data/lib/state_machine/callback.rb +241 -0
  29. data/lib/state_machine/condition_proxy.rb +106 -0
  30. data/lib/state_machine/eval_helpers.rb +83 -0
  31. data/lib/state_machine/event.rb +267 -0
  32. data/lib/state_machine/event_collection.rb +122 -0
  33. data/lib/state_machine/extensions.rb +149 -0
  34. data/lib/state_machine/guard.rb +230 -0
  35. data/lib/state_machine/initializers/merb.rb +1 -0
  36. data/lib/state_machine/initializers/rails.rb +5 -0
  37. data/lib/state_machine/initializers.rb +4 -0
  38. data/lib/state_machine/integrations/active_model/locale.rb +11 -0
  39. data/lib/state_machine/integrations/active_model/observer.rb +45 -0
  40. data/lib/state_machine/integrations/active_model.rb +445 -0
  41. data/lib/state_machine/integrations/active_record/locale.rb +20 -0
  42. data/lib/state_machine/integrations/active_record.rb +522 -0
  43. data/lib/state_machine/integrations/data_mapper/observer.rb +175 -0
  44. data/lib/state_machine/integrations/data_mapper.rb +379 -0
  45. data/lib/state_machine/integrations/mongo_mapper.rb +309 -0
  46. data/lib/state_machine/integrations/sequel.rb +356 -0
  47. data/lib/state_machine/integrations.rb +83 -0
  48. data/lib/state_machine/machine.rb +1645 -0
  49. data/lib/state_machine/machine_collection.rb +64 -0
  50. data/lib/state_machine/matcher.rb +123 -0
  51. data/lib/state_machine/matcher_helpers.rb +54 -0
  52. data/lib/state_machine/node_collection.rb +152 -0
  53. data/lib/state_machine/state.rb +260 -0
  54. data/lib/state_machine/state_collection.rb +112 -0
  55. data/lib/state_machine/transition.rb +399 -0
  56. data/lib/state_machine/transition_collection.rb +244 -0
  57. data/lib/state_machine.rb +421 -0
  58. data/lib/tasks/state_machine.rake +1 -0
  59. data/lib/tasks/state_machine.rb +27 -0
  60. data/test/files/en.yml +9 -0
  61. data/test/files/switch.rb +11 -0
  62. data/test/functional/state_machine_test.rb +980 -0
  63. data/test/test_helper.rb +4 -0
  64. data/test/unit/assertions_test.rb +40 -0
  65. data/test/unit/callback_test.rb +728 -0
  66. data/test/unit/condition_proxy_test.rb +328 -0
  67. data/test/unit/eval_helpers_test.rb +222 -0
  68. data/test/unit/event_collection_test.rb +324 -0
  69. data/test/unit/event_test.rb +795 -0
  70. data/test/unit/guard_test.rb +909 -0
  71. data/test/unit/integrations/active_model_test.rb +956 -0
  72. data/test/unit/integrations/active_record_test.rb +1918 -0
  73. data/test/unit/integrations/data_mapper_test.rb +1814 -0
  74. data/test/unit/integrations/mongo_mapper_test.rb +1382 -0
  75. data/test/unit/integrations/sequel_test.rb +1492 -0
  76. data/test/unit/integrations_test.rb +50 -0
  77. data/test/unit/invalid_event_test.rb +7 -0
  78. data/test/unit/invalid_transition_test.rb +7 -0
  79. data/test/unit/machine_collection_test.rb +565 -0
  80. data/test/unit/machine_test.rb +2349 -0
  81. data/test/unit/matcher_helpers_test.rb +37 -0
  82. data/test/unit/matcher_test.rb +155 -0
  83. data/test/unit/node_collection_test.rb +207 -0
  84. data/test/unit/state_collection_test.rb +280 -0
  85. data/test/unit/state_machine_test.rb +31 -0
  86. data/test/unit/state_test.rb +848 -0
  87. data/test/unit/transition_collection_test.rb +2098 -0
  88. data/test/unit/transition_test.rb +1384 -0
  89. metadata +176 -0
@@ -0,0 +1,112 @@
1
+ require 'state_machine/node_collection'
2
+
3
+ module StateMachine
4
+ # Represents a collection of states in a state machine
5
+ class StateCollection < NodeCollection
6
+ def initialize(machine) #:nodoc:
7
+ super(machine, :index => [:name, :value])
8
+ end
9
+
10
+ # Determines whether the given object is in a specific state. If the
11
+ # object's current value doesn't match the state, then this will return
12
+ # false, otherwise true. If the given state is unknown, then an IndexError
13
+ # will be raised.
14
+ #
15
+ # == Examples
16
+ #
17
+ # class Vehicle
18
+ # state_machine :initial => :parked do
19
+ # other_states :idling
20
+ # end
21
+ # end
22
+ #
23
+ # states = Vehicle.state_machine.states
24
+ # vehicle = Vehicle.new # => #<Vehicle:0xb7c464b0 @state="parked">
25
+ #
26
+ # states.matches?(vehicle, :parked) # => true
27
+ # states.matches?(vehicle, :idling) # => false
28
+ # states.matches?(vehicle, :invalid) # => IndexError: :invalid is an invalid key for :name index
29
+ def matches?(object, name)
30
+ fetch(name).matches?(machine.read(object, :state))
31
+ end
32
+
33
+ # Determines the current state of the given object as configured by this
34
+ # state machine. This will attempt to find a known state that matches
35
+ # the value of the attribute on the object.
36
+ #
37
+ # == Examples
38
+ #
39
+ # class Vehicle
40
+ # state_machine :initial => :parked do
41
+ # other_states :idling
42
+ # end
43
+ # end
44
+ #
45
+ # states = Vehicle.state_machine.states
46
+ #
47
+ # vehicle = Vehicle.new # => #<Vehicle:0xb7c464b0 @state="parked">
48
+ # states.match(vehicle) # => #<StateMachine::State name=:parked value="parked" initial=true>
49
+ #
50
+ # vehicle.state = 'idling'
51
+ # states.match(vehicle) # => #<StateMachine::State name=:idling value="idling" initial=true>
52
+ #
53
+ # vehicle.state = 'invalid'
54
+ # states.match(vehicle) # => nil
55
+ def match(object)
56
+ value = machine.read(object, :state)
57
+ self[value, :value] || detect {|state| state.matches?(value)}
58
+ end
59
+
60
+ # Determines the current state of the given object as configured by this
61
+ # state machine. If no state is found, then an ArgumentError will be
62
+ # raised.
63
+ #
64
+ # == Examples
65
+ #
66
+ # class Vehicle
67
+ # state_machine :initial => :parked do
68
+ # other_states :idling
69
+ # end
70
+ # end
71
+ #
72
+ # states = Vehicle.state_machine.states
73
+ #
74
+ # vehicle = Vehicle.new # => #<Vehicle:0xb7c464b0 @state="parked">
75
+ # states.match!(vehicle) # => #<StateMachine::State name=:parked value="parked" initial=true>
76
+ #
77
+ # vehicle.state = 'invalid'
78
+ # states.match!(vehicle) # => ArgumentError: "invalid" is not a known state value
79
+ def match!(object)
80
+ match(object) || raise(ArgumentError, "#{machine.read(object, :state).inspect} is not a known #{machine.name} value")
81
+ end
82
+
83
+ # Gets the order in which states should be displayed based on where they
84
+ # were first referenced. This will order states in the following priority:
85
+ #
86
+ # 1. Initial state
87
+ # 2. Event transitions (:from, :except_from, :to, :except_to options)
88
+ # 3. States with behaviors
89
+ # 4. States referenced via +state+ or +other_states+
90
+ # 5. States referenced in callbacks
91
+ #
92
+ # This order will determine how the GraphViz visualizations are rendered.
93
+ def by_priority
94
+ order = select {|state| state.initial}.map {|state| state.name}
95
+
96
+ machine.events.each {|event| order += event.known_states}
97
+ order += select {|state| state.methods.any?}.map {|state| state.name}
98
+ order += keys(:name) - machine.callbacks.values.flatten.map {|callback| callback.known_states}.flatten
99
+ order += keys(:name)
100
+
101
+ order.uniq!
102
+ order.map! {|name| self[name]}
103
+ order
104
+ end
105
+
106
+ private
107
+ # Gets the value for the given attribute on the node
108
+ def value(node, attribute)
109
+ attribute == :value ? node.value(false) : super
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,399 @@
1
+ require 'state_machine/transition_collection'
2
+
3
+ module StateMachine
4
+ # An invalid transition was attempted
5
+ class InvalidTransition < StandardError
6
+ end
7
+
8
+ # A transition represents a state change for a specific attribute.
9
+ #
10
+ # Transitions consist of:
11
+ # * An event
12
+ # * A starting state
13
+ # * An ending state
14
+ class Transition
15
+ # The object being transitioned
16
+ attr_reader :object
17
+
18
+ # The state machine for which this transition is defined
19
+ attr_reader :machine
20
+
21
+ # The original state value *before* the transition
22
+ attr_reader :from
23
+
24
+ # The new state value *after* the transition
25
+ attr_reader :to
26
+
27
+ # The arguments passed in to the event that triggered the transition
28
+ # (does not include the +run_action+ boolean argument if specified)
29
+ attr_accessor :args
30
+
31
+ # The result of invoking the action associated with the machine
32
+ attr_reader :result
33
+
34
+ # Whether the transition is only existing temporarily for the object
35
+ attr_writer :transient
36
+
37
+ # Creates a new, specific transition
38
+ def initialize(object, machine, event, from_name, to_name, read_state = true) #:nodoc:
39
+ @object = object
40
+ @machine = machine
41
+ @args = []
42
+ @transient = false
43
+
44
+ @event = machine.events.fetch(event)
45
+ @from_state = machine.states.fetch(from_name)
46
+ @from = read_state ? machine.read(object, :state) : @from_state.value
47
+ @to_state = machine.states.fetch(to_name)
48
+ @to = @to_state.value
49
+
50
+ reset
51
+ end
52
+
53
+ # The attribute which this transition's machine is defined for
54
+ def attribute
55
+ machine.attribute
56
+ end
57
+
58
+ # The action that will be run when this transition is performed
59
+ def action
60
+ machine.action
61
+ end
62
+
63
+ # The event that triggered the transition
64
+ def event
65
+ @event.name
66
+ end
67
+
68
+ # The fully-qualified name of the event that triggered the transition
69
+ def qualified_event
70
+ @event.qualified_name
71
+ end
72
+
73
+ # The human-readable name of the event that triggered the transition
74
+ def human_event
75
+ @event.human_name(@object.class)
76
+ end
77
+
78
+ # The state name *before* the transition
79
+ def from_name
80
+ @from_state.name
81
+ end
82
+
83
+ # The fully-qualified state name *before* the transition
84
+ def qualified_from_name
85
+ @from_state.qualified_name
86
+ end
87
+
88
+ # The human-readable state name *before* the transition
89
+ def human_from_name
90
+ @from_state.human_name(@object.class)
91
+ end
92
+
93
+ # The new state name *after* the transition
94
+ def to_name
95
+ @to_state.name
96
+ end
97
+
98
+ # The new fully-qualified state name *after* the transition
99
+ def qualified_to_name
100
+ @to_state.qualified_name
101
+ end
102
+
103
+ # The new human-readable state name *after* the transition
104
+ def human_to_name
105
+ @to_state.human_name(@object.class)
106
+ end
107
+
108
+ # Does this transition represent a loopback (i.e. the from and to state
109
+ # are the same)
110
+ #
111
+ # == Example
112
+ #
113
+ # machine = StateMachine.new(Vehicle)
114
+ # StateMachine::Transition.new(Vehicle.new, machine, :park, :parked, :parked).loopback? # => true
115
+ # StateMachine::Transition.new(Vehicle.new, machine, :park, :idling, :parked).loopback? # => false
116
+ def loopback?
117
+ from_name == to_name
118
+ end
119
+
120
+ # Is this transition existing for a short period only? If this is set, it
121
+ # indicates that the transition (or the event backing it) should not be
122
+ # written to the object if it fails.
123
+ def transient?
124
+ @transient
125
+ end
126
+
127
+ # A hash of all the core attributes defined for this transition with their
128
+ # names as keys and values of the attributes as values.
129
+ #
130
+ # == Example
131
+ #
132
+ # machine = StateMachine.new(Vehicle)
133
+ # transition = StateMachine::Transition.new(Vehicle.new, machine, :ignite, :parked, :idling)
134
+ # transition.attributes # => {:object => #<Vehicle:0xb7d60ea4>, :attribute => :state, :event => :ignite, :from => 'parked', :to => 'idling'}
135
+ def attributes
136
+ @attributes ||= {:object => object, :attribute => attribute, :event => event, :from => from, :to => to}
137
+ end
138
+
139
+ # Runs the actual transition and any before/after callbacks associated
140
+ # with the transition. The action associated with the transition/machine
141
+ # can be skipped by passing in +false+.
142
+ #
143
+ # == Examples
144
+ #
145
+ # class Vehicle
146
+ # state_machine :action => :save do
147
+ # ...
148
+ # end
149
+ # end
150
+ #
151
+ # vehicle = Vehicle.new
152
+ # transition = StateMachine::Transition.new(vehicle, machine, :ignite, :parked, :idling)
153
+ # transition.perform # => Runs the +save+ action after setting the state attribute
154
+ # transition.perform(false) # => Only sets the state attribute
155
+ # transition.perform(Time.now) # => Passes in additional arguments and runs the +save+ action
156
+ # transition.perform(Time.now, false) # => Passes in additional arguments and only sets the state attribute
157
+ def perform(*args)
158
+ run_action = [true, false].include?(args.last) ? args.pop : true
159
+ self.args = args
160
+
161
+ # Run the transition
162
+ !!TransitionCollection.new([self], :actions => run_action).perform
163
+ end
164
+
165
+ # Runs a block within a transaction for the object being transitioned.
166
+ # By default, transactions are a no-op unless otherwise defined by the
167
+ # machine's integration.
168
+ def within_transaction
169
+ machine.within_transaction(object) do
170
+ yield
171
+ end
172
+ end
173
+
174
+ # Runs the before / after callbacks for this transition. If a block is
175
+ # provided, then it will be executed between the before and after callbacks.
176
+ #
177
+ # Configuration options:
178
+ # * +after+ - Whether to run after callbacks. If false, then any around
179
+ # callbacks will be paused until called again with +after+ enabled.
180
+ # Default is true.
181
+ #
182
+ # This will return true if all before callbacks gets executed. After
183
+ # callbacks will not have an effect on the result.
184
+ def run_callbacks(options = {}, &block)
185
+ options = {:after => true}.merge(options)
186
+ @success = false
187
+
188
+ halted = pausable { before(options[:after], &block) }
189
+
190
+ # After callbacks are only run if:
191
+ # * An around callback didn't halt after yielding
192
+ # * They're enabled or the run didn't succeed
193
+ after if !(@before_run && halted) && (options[:after] || !@success)
194
+
195
+ @before_run
196
+ end
197
+
198
+ # Transitions the current value of the state to that specified by the
199
+ # transition. Once the state is persisted, it cannot be persisted again
200
+ # until this transition is reset.
201
+ #
202
+ # == Example
203
+ #
204
+ # class Vehicle
205
+ # state_machine do
206
+ # event :ignite do
207
+ # transition :parked => :idling
208
+ # end
209
+ # end
210
+ # end
211
+ #
212
+ # vehicle = Vehicle.new
213
+ # transition = StateMachine::Transition.new(vehicle, Vehicle.state_machine, :ignite, :parked, :idling)
214
+ # transition.persist
215
+ #
216
+ # vehicle.state # => 'idling'
217
+ def persist
218
+ unless @persisted
219
+ machine.write(object, :state, to)
220
+ @persisted = true
221
+ end
222
+ end
223
+
224
+ # Rolls back changes made to the object's state via this transition. This
225
+ # will revert the state back to the +from+ value.
226
+ #
227
+ # == Example
228
+ #
229
+ # class Vehicle
230
+ # state_machine :initial => :parked do
231
+ # event :ignite do
232
+ # transition :parked => :idling
233
+ # end
234
+ # end
235
+ # end
236
+ #
237
+ # vehicle = Vehicle.new # => #<Vehicle:0xb7b7f568 @state="parked">
238
+ # transition = StateMachine::Transition.new(vehicle, Vehicle.state_machine, :ignite, :parked, :idling)
239
+ #
240
+ # # Persist the new state
241
+ # vehicle.state # => "parked"
242
+ # transition.persist
243
+ # vehicle.state # => "idling"
244
+ #
245
+ # # Roll back to the original state
246
+ # transition.rollback
247
+ # vehicle.state # => "parked"
248
+ def rollback
249
+ reset
250
+ machine.write(object, :state, from)
251
+ end
252
+
253
+ # Resets any tracking of which callbacks have already been run and whether
254
+ # the state has already been persisted
255
+ def reset
256
+ @before_run = @persisted = @after_run = false
257
+ @paused_block = nil
258
+ end
259
+
260
+ # Generates a nicely formatted description of this transitions's contents.
261
+ #
262
+ # For example,
263
+ #
264
+ # transition = StateMachine::Transition.new(object, machine, :ignite, :parked, :idling)
265
+ # transition # => #<StateMachine::Transition attribute=:state event=:ignite from="parked" from_name=:parked to="idling" to_name=:idling>
266
+ def inspect
267
+ "#<#{self.class} #{%w(attribute event from from_name to to_name).map {|attr| "#{attr}=#{send(attr).inspect}"} * ' '}>"
268
+ end
269
+
270
+ private
271
+ # Runs a block that may get paused. If the block doesn't pause, then
272
+ # execution will continue as normal. If the block gets paused, then it
273
+ # will take care of switching the execution context when it's resumed.
274
+ #
275
+ # This will return true if the given block halts for a reason other than
276
+ # getting paused.
277
+ def pausable
278
+ begin
279
+ halted = !catch(:halt) { yield; true }
280
+ rescue Exception => error
281
+ raise unless @resume_block
282
+ end
283
+
284
+ if @resume_block
285
+ @resume_block.call(halted, error)
286
+ else
287
+ halted
288
+ end
289
+ end
290
+
291
+ # Pauses the current callback execution. This should only occur within
292
+ # around callbacks when the remainder of the callback will be executed at
293
+ # a later point in time.
294
+ def pause
295
+ unless @resume_block
296
+ require 'continuation' unless defined?(callcc)
297
+ callcc do |block|
298
+ @paused_block = block
299
+ throw :halt, true
300
+ end
301
+ end
302
+ end
303
+
304
+ # Resumes the execution of a previously paused callback execution. Once
305
+ # the paused callbacks complete, the current execution will continue.
306
+ def resume
307
+ if @paused_block
308
+ halted, error = callcc do |block|
309
+ @resume_block = block
310
+ @paused_block.call
311
+ end
312
+
313
+ @resume_block = @paused_block = nil
314
+
315
+ raise error if error
316
+ !halted
317
+ else
318
+ true
319
+ end
320
+ end
321
+
322
+ # Runs the machine's +before+ callbacks for this transition. Only
323
+ # callbacks that are configured to match the event, from state, and to
324
+ # state will be invoked.
325
+ #
326
+ # Once the callbacks are run, they cannot be run again until this transition
327
+ # is reset.
328
+ def before(complete = true, index = 0, &block)
329
+ unless @before_run
330
+ while callback = machine.callbacks[:before][index]
331
+ index += 1
332
+
333
+ if callback.type == :around
334
+ # Around callback: need to handle recursively. Execution only gets
335
+ # paused if:
336
+ # * The block fails and the callback doesn't run on failures OR
337
+ # * The block succeeds, but after callbacks are disabled (in which
338
+ # case a continuation is stored for later execution)
339
+ return if catch(:cancel) do
340
+ callback.call(object, context, self) do
341
+ before(complete, index, &block)
342
+
343
+ pause if @success && !complete
344
+ throw :cancel, true unless callback.matches_success?(@success)
345
+ end
346
+ end
347
+ else
348
+ # Normal before callback
349
+ callback.call(object, context, self)
350
+ end
351
+ end
352
+
353
+ @before_run = true
354
+ end
355
+
356
+ action = {:success => true}.merge(block_given? ? yield : {})
357
+ @result, @success = action[:result], action[:success]
358
+ end
359
+
360
+ # Runs the machine's +after+ callbacks for this transition. Only
361
+ # callbacks that are configured to match the event, from state, and to
362
+ # state will be invoked.
363
+ #
364
+ # Once the callbacks are run, they cannot be run again until this transition
365
+ # is reset.
366
+ #
367
+ # == Halting
368
+ #
369
+ # If any callback throws a <tt>:halt</tt> exception, it will be caught
370
+ # and the callback chain will be automatically stopped. However, this
371
+ # exception will not bubble up to the caller since +after+ callbacks
372
+ # should never halt the execution of a +perform+.
373
+ def after
374
+ unless @after_run
375
+ # First resume previously paused callbacks
376
+ if resume
377
+ catch(:halt) do
378
+ after_context = context.merge(:success => @success)
379
+ machine.callbacks[:after].each {|callback| callback.call(object, after_context, self)}
380
+ end
381
+ end
382
+
383
+ @after_run = true
384
+ end
385
+ end
386
+
387
+ # Gets a hash of the context defining this unique transition (including
388
+ # event, from state, and to state).
389
+ #
390
+ # == Example
391
+ #
392
+ # machine = StateMachine.new(Vehicle)
393
+ # transition = StateMachine::Transition.new(Vehicle.new, machine, :ignite, :parked, :idling)
394
+ # transition.context # => {:on => :ignite, :from => :parked, :to => :idling}
395
+ def context
396
+ @context ||= {:on => event, :from => from_name, :to => to_name}
397
+ end
398
+ end
399
+ end