state_machine 0.8.1 → 0.9.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 (42) hide show
  1. data/CHANGELOG.rdoc +17 -0
  2. data/LICENSE +1 -1
  3. data/README.rdoc +162 -23
  4. data/Rakefile +3 -18
  5. data/lib/state_machine.rb +3 -4
  6. data/lib/state_machine/callback.rb +65 -13
  7. data/lib/state_machine/eval_helpers.rb +20 -4
  8. data/lib/state_machine/initializers.rb +4 -0
  9. data/lib/state_machine/initializers/merb.rb +1 -0
  10. data/lib/state_machine/initializers/rails.rb +7 -0
  11. data/lib/state_machine/integrations.rb +21 -6
  12. data/lib/state_machine/integrations/active_model.rb +414 -0
  13. data/lib/state_machine/integrations/active_model/locale.rb +11 -0
  14. data/lib/state_machine/integrations/{active_record → active_model}/observer.rb +7 -7
  15. data/lib/state_machine/integrations/active_record.rb +65 -129
  16. data/lib/state_machine/integrations/active_record/locale.rb +4 -11
  17. data/lib/state_machine/integrations/data_mapper.rb +24 -6
  18. data/lib/state_machine/integrations/data_mapper/observer.rb +36 -0
  19. data/lib/state_machine/integrations/mongo_mapper.rb +295 -0
  20. data/lib/state_machine/integrations/sequel.rb +33 -7
  21. data/lib/state_machine/machine.rb +121 -23
  22. data/lib/state_machine/machine_collection.rb +12 -103
  23. data/lib/state_machine/transition.rb +125 -164
  24. data/lib/state_machine/transition_collection.rb +244 -0
  25. data/lib/tasks/state_machine.rb +12 -15
  26. data/test/functional/state_machine_test.rb +11 -1
  27. data/test/unit/callback_test.rb +305 -32
  28. data/test/unit/eval_helpers_test.rb +103 -1
  29. data/test/unit/event_test.rb +2 -1
  30. data/test/unit/guard_test.rb +2 -1
  31. data/test/unit/integrations/active_model_test.rb +909 -0
  32. data/test/unit/integrations/active_record_test.rb +1542 -1292
  33. data/test/unit/integrations/data_mapper_test.rb +1369 -1041
  34. data/test/unit/integrations/mongo_mapper_test.rb +1349 -0
  35. data/test/unit/integrations/sequel_test.rb +1214 -985
  36. data/test/unit/integrations_test.rb +8 -0
  37. data/test/unit/machine_collection_test.rb +140 -513
  38. data/test/unit/machine_test.rb +212 -10
  39. data/test/unit/state_test.rb +2 -1
  40. data/test/unit/transition_collection_test.rb +2098 -0
  41. data/test/unit/transition_test.rb +704 -552
  42. metadata +16 -3
@@ -1,3 +1,5 @@
1
+ require 'state_machine/transition_collection'
2
+
1
3
  module StateMachine
2
4
  # An invalid transition was attempted
3
5
  class InvalidTransition < StandardError
@@ -10,83 +12,6 @@ module StateMachine
10
12
  # * A starting state
11
13
  # * An ending state
12
14
  class Transition
13
- class << self
14
- # Runs one or more transitions in parallel. All transitions will run
15
- # through the following steps:
16
- # 1. Before callbacks
17
- # 2. Persist state
18
- # 3. Invoke action
19
- # 4. After callbacks (if configured)
20
- # 5. Rollback (if action is unsuccessful)
21
- #
22
- # Configuration options:
23
- # * <tt>:action</tt> - Whether to run the action configured for each transition
24
- # * <tt>:after</tt> - Whether to run after callbacks
25
- #
26
- # If a block is passed to this method, that block will be called instead
27
- # of invoking each transition's action.
28
- def perform(transitions, options = {})
29
- # Validate that the transitions are for separate machines / attributes
30
- attributes = transitions.map {|transition| transition.attribute}.uniq
31
- raise ArgumentError, 'Cannot perform multiple transitions in parallel for the same state machine attribute' if attributes.length != transitions.length
32
-
33
- success = false
34
-
35
- # Run before callbacks. If any callback halts, then the entire chain
36
- # is halted for every transition.
37
- if transitions.all? {|transition| transition.before}
38
- # Persist the new state for each attribute
39
- transitions.each {|transition| transition.persist}
40
-
41
- # Run the actions associated with each machine
42
- begin
43
- results = {}
44
- success =
45
- if block_given?
46
- # Block was given: use the result for each transition
47
- result = yield
48
- transitions.each {|transition| results[transition.action] = result}
49
- !!result
50
- elsif options[:action] == false
51
- # Skip the action
52
- true
53
- else
54
- # Run each transition's action (only once)
55
- object = transitions.first.object
56
- transitions.all? do |transition|
57
- action = transition.action
58
- action && !results.include?(action) ? results[action] = object.send(action) : true
59
- end
60
- end
61
- rescue Exception
62
- # Action failed: rollback
63
- transitions.each {|transition| transition.rollback}
64
- raise
65
- end
66
-
67
- # Run after callbacks even when the actions failed. The :after option
68
- # is ignored if the transitions were unsuccessful.
69
- transitions.each {|transition| transition.after(results[transition.action], success)} unless options[:after] == false && success
70
-
71
- # Rollback the transitions if the transaction was unsuccessful
72
- transitions.each {|transition| transition.rollback} unless success
73
- end
74
-
75
- success
76
- end
77
-
78
- # Runs one or more transitions within a transaction. See StateMachine::Transition.perform
79
- # for more information.
80
- def perform_within_transaction(transitions, options = {})
81
- success = false
82
- transitions.first.within_transaction do
83
- success = perform(transitions, options)
84
- end
85
-
86
- success
87
- end
88
- end
89
-
90
15
  # The object being transitioned
91
16
  attr_reader :object
92
17
 
@@ -124,11 +49,15 @@ module StateMachine
124
49
  # The result of invoking the action associated with the machine
125
50
  attr_reader :result
126
51
 
52
+ # Whether the transition is only existing temporarily for the object
53
+ attr_writer :transient
54
+
127
55
  # Creates a new, specific transition
128
56
  def initialize(object, machine, event, from_name, to_name, read_state = true) #:nodoc:
129
57
  @object = object
130
58
  @machine = machine
131
59
  @args = []
60
+ @transient = false
132
61
 
133
62
  # Event information
134
63
  event = machine.events.fetch(event)
@@ -146,6 +75,8 @@ module StateMachine
146
75
  @to = to_state.value
147
76
  @to_name = to_state.name
148
77
  @qualified_to_name = to_state.qualified_name
78
+
79
+ reset
149
80
  end
150
81
 
151
82
  # The attribute which this transition's machine is defined for
@@ -170,6 +101,13 @@ module StateMachine
170
101
  from_name == to_name
171
102
  end
172
103
 
104
+ # Is this transition existing for a short period only? If this is set, it
105
+ # indicates that the transition (or the event backing it) should not be
106
+ # written to the object if it fails.
107
+ def transient?
108
+ @transient
109
+ end
110
+
173
111
  # A hash of all the core attributes defined for this transition with their
174
112
  # names as keys and values of the attributes as values.
175
113
  #
@@ -203,7 +141,7 @@ module StateMachine
203
141
  self.args = args
204
142
 
205
143
  # Run the transition
206
- self.class.perform_within_transaction([self], :action => run_action)
144
+ !!TransitionCollection.new([self], :actions => run_action).perform
207
145
  end
208
146
 
209
147
  # Runs a block within a transaction for the object being transitioned.
@@ -215,37 +153,39 @@ module StateMachine
215
153
  end
216
154
  end
217
155
 
218
- # Runs the machine's +before+ callbacks for this transition. Only
219
- # callbacks that are configured to match the event, from state, and to
220
- # state will be invoked.
221
- #
222
- # Once the callbacks are run, they cannot be run again until this transition
223
- # is reset.
156
+ # Runs the before / after callbacks for this transition. If a block is
157
+ # provided, then it will be executed between the before and after callbacks.
224
158
  #
225
- # == Example
159
+ # Configuration options:
160
+ # * +after+ - Whether to run after callbacks. If false, then any around
161
+ # callbacks will be paused until called again with +after+ enabled.
162
+ # Default is true.
226
163
  #
227
- # class Vehicle
228
- # state_machine do
229
- # before_transition :on => :ignite, :do => lambda {|vehicle| ...}
230
- # end
231
- # end
232
- #
233
- # vehicle = Vehicle.new
234
- # transition = StateMachine::Transition.new(vehicle, machine, :ignite, :parked, :idling)
235
- # transition.before
236
- def before
237
- result = false
164
+ # This will return true if all before callbacks gets executed. After
165
+ # callbacks will not have an effect on the result.
166
+ def run_callbacks(options = {}, &block)
167
+ options = {:after => true}.merge(options)
168
+ @success = false
238
169
 
239
- catch(:halt) do
240
- unless @before_run
241
- callback(:before)
242
- @before_run = true
243
- end
244
-
245
- result = true
170
+ # Run before callbacks. :halt is caught here so that it rolls up through
171
+ # any around callbacks.
172
+ begin
173
+ halted = !catch(:halt) { before(options[:after], &block); true }
174
+ rescue Exception => error
175
+ raise unless @after_block
176
+ end
177
+
178
+ # After callbacks are only run if:
179
+ # * There isn't an after block already running
180
+ # * An around callback didn't halt after yielding
181
+ # * They're enabled or the run didn't succeed
182
+ if @after_block
183
+ @after_block.call(halted, error)
184
+ elsif !(@before_run && halted) && (options[:after] || !@success)
185
+ after
246
186
  end
247
187
 
248
- result
188
+ @before_run
249
189
  end
250
190
 
251
191
  # Transitions the current value of the state to that specified by the
@@ -274,51 +214,6 @@ module StateMachine
274
214
  end
275
215
  end
276
216
 
277
- # Runs the machine's +after+ callbacks for this transition. Only
278
- # callbacks that are configured to match the event, from state, and to
279
- # state will be invoked.
280
- #
281
- # The result can be used to indicate whether the associated machine action
282
- # was executed successfully.
283
- #
284
- # Once the callbacks are run, they cannot be run again until this transition
285
- # is reset.
286
- #
287
- # == Halting
288
- #
289
- # If any callback throws a <tt>:halt</tt> exception, it will be caught
290
- # and the callback chain will be automatically stopped. However, this
291
- # exception will not bubble up to the caller since +after+ callbacks
292
- # should never halt the execution of a +perform+.
293
- #
294
- # == Example
295
- #
296
- # class Vehicle
297
- # state_machine do
298
- # after_transition :on => :ignite, :do => lambda {|vehicle| ...}
299
- #
300
- # event :ignite do
301
- # transition :parked => :idling
302
- # end
303
- # end
304
- # end
305
- #
306
- # vehicle = Vehicle.new
307
- # transition = StateMachine::Transition.new(vehicle, Vehicle.state_machine, :ignite, :parked, :idling)
308
- # transition.after(true)
309
- def after(result = nil, success = true)
310
- @result = result
311
-
312
- catch(:halt) do
313
- unless @after_run
314
- callback(:after, :success => success)
315
- @after_run = true
316
- end
317
- end
318
-
319
- true
320
- end
321
-
322
217
  # Rolls back changes made to the object's state via this transition. This
323
218
  # will revert the state back to the +from+ value.
324
219
  #
@@ -352,6 +247,7 @@ module StateMachine
352
247
  # the state has already been persisted
353
248
  def reset
354
249
  @before_run = @persisted = @after_run = false
250
+ @around_block = nil
355
251
  end
356
252
 
357
253
  # Generates a nicely formatted description of this transitions's contents.
@@ -364,7 +260,86 @@ module StateMachine
364
260
  "#<#{self.class} #{%w(attribute event from from_name to to_name).map {|attr| "#{attr}=#{send(attr).inspect}"} * ' '}>"
365
261
  end
366
262
 
367
- protected
263
+ private
264
+ # Runs the machine's +before+ callbacks for this transition. Only
265
+ # callbacks that are configured to match the event, from state, and to
266
+ # state will be invoked.
267
+ #
268
+ # Once the callbacks are run, they cannot be run again until this transition
269
+ # is reset.
270
+ def before(complete = true, index = 0, &block)
271
+ unless @before_run
272
+ while callback = machine.callbacks[:before][index]
273
+ index += 1
274
+
275
+ if callback.type == :around
276
+ # Around callback: need to handle recursively. Execution only gets
277
+ # paused if:
278
+ # * The block fails and the callback doesn't run on failures OR
279
+ # * The block succeeds, but after callbacks are disabled (in which
280
+ # case a continuation is stored for later execution)
281
+ return if catch(:pause) do
282
+ callback.call(object, context, self) do
283
+ before(complete, index, &block)
284
+
285
+ if @success && !complete && !@around_block && !@after_block
286
+ require 'continuation' unless defined?(callcc)
287
+ callcc {|block| @around_block = block}
288
+ end
289
+
290
+ throw :pause, true if @around_block && !@after_block || !callback.matches_success?(@success)
291
+ end
292
+ end
293
+ else
294
+ # Normal before callback
295
+ callback.call(object, context, self)
296
+ end
297
+ end
298
+
299
+ @before_run = true
300
+ end
301
+
302
+ action = {:success => true}.merge(block_given? ? yield : {})
303
+ @result, @success = action[:result], action[:success]
304
+ end
305
+
306
+ # Runs the machine's +after+ callbacks for this transition. Only
307
+ # callbacks that are configured to match the event, from state, and to
308
+ # state will be invoked.
309
+ #
310
+ # Once the callbacks are run, they cannot be run again until this transition
311
+ # is reset.
312
+ #
313
+ # == Halting
314
+ #
315
+ # If any callback throws a <tt>:halt</tt> exception, it will be caught
316
+ # and the callback chain will be automatically stopped. However, this
317
+ # exception will not bubble up to the caller since +after+ callbacks
318
+ # should never halt the execution of a +perform+.
319
+ def after
320
+ unless @after_run
321
+ catch(:halt) do
322
+ # First call any yielded around blocks
323
+ if @around_block
324
+ halted, error = callcc do |block|
325
+ @after_block = block
326
+ @around_block.call
327
+ end
328
+
329
+ @after_block = @around_block = nil
330
+ raise error if error
331
+ throw :halt if halted
332
+ end
333
+
334
+ # Call normal after callbacks in order
335
+ after_context = context.merge(:success => @success)
336
+ machine.callbacks[:after].each {|callback| callback.call(object, after_context, self)}
337
+ end
338
+
339
+ @after_run = true
340
+ end
341
+ end
342
+
368
343
  # Gets a hash of the context defining this unique transition (including
369
344
  # event, from state, and to state).
370
345
  #
@@ -376,19 +351,5 @@ module StateMachine
376
351
  def context
377
352
  @context ||= {:on => event, :from => from_name, :to => to_name}
378
353
  end
379
-
380
- # Runs the callbacks of the given type for this transition. This will
381
- # only invoke callbacks that exactly match the event, from state, and
382
- # to state that describe this transition.
383
- #
384
- # Additional callback parameters can be specified. By default, this
385
- # transition is also passed into callbacks.
386
- def callback(type, context = {})
387
- context = self.context.merge(context)
388
-
389
- machine.callbacks[type].each do |callback|
390
- callback.call(object, context, self)
391
- end
392
- end
393
354
  end
394
355
  end
@@ -0,0 +1,244 @@
1
+ module StateMachine
2
+ # Represents a collection of transitions in a state machine
3
+ class TransitionCollection < Array
4
+ include Assertions
5
+
6
+ # Whether to skip running the action for each transition's machine
7
+ attr_reader :skip_actions
8
+
9
+ # Whether to skip running the after callbacks
10
+ attr_reader :skip_after
11
+
12
+ # Whether transitions should wrapped around a transaction block
13
+ attr_reader :use_transaction
14
+
15
+ # Creates a new collection of transitions that can be run in parallel. Each
16
+ # transition *must* be for a different attribute.
17
+ #
18
+ # Configuration options:
19
+ # * <tt>:actions</tt> - Whether to run the action configured for each transition
20
+ # * <tt>:after</tt> - Whether to run after callbacks
21
+ # * <tt>:transaction</tt> - Whether to wrap transitions within a transaction
22
+ def initialize(transitions = [], options = {})
23
+ super(transitions)
24
+
25
+ # Determine the validity of the transitions as a whole
26
+ @valid = all?
27
+ reject! {|transition| !transition}
28
+
29
+ attributes = map {|transition| transition.attribute}.uniq
30
+ raise ArgumentError, 'Cannot perform multiple transitions in parallel for the same state machine attribute' if attributes.length != length
31
+
32
+ assert_valid_keys(options, :actions, :after, :transaction)
33
+ options = {:actions => true, :after => true, :transaction => true}.merge(options)
34
+ @skip_actions = !options[:actions]
35
+ @skip_after = !options[:after]
36
+ @use_transaction = options[:transaction]
37
+ end
38
+
39
+ # Runs each of the collection's transitions in parallel.
40
+ #
41
+ # All transitions will run through the following steps:
42
+ # 1. Before callbacks
43
+ # 2. Persist state
44
+ # 3. Invoke action
45
+ # 4. After callbacks (if configured)
46
+ # 5. Rollback (if action is unsuccessful)
47
+ #
48
+ # If a block is passed to this method, that block will be called instead
49
+ # of invoking each transition's action.
50
+ def perform(&block)
51
+ reset
52
+
53
+ if valid?
54
+ if use_event_attributes? && !block_given?
55
+ each do |transition|
56
+ transition.transient = true
57
+ transition.machine.write(object, :event_transition, transition)
58
+ end
59
+
60
+ run_actions
61
+ else
62
+ within_transaction do
63
+ catch(:halt) { run_callbacks(&block) }
64
+ rollback unless success?
65
+ end
66
+ end
67
+ end
68
+
69
+ if actions.length == 1 && results.include?(actions.first)
70
+ results[actions.first]
71
+ else
72
+ success?
73
+ end
74
+ end
75
+
76
+ private
77
+ attr_reader :results #:nodoc:
78
+
79
+ # Is this a valid set of transitions? If the collection was creating with
80
+ # any +false+ values for transitions, then the the collection will be
81
+ # marked as invalid.
82
+ def valid?
83
+ @valid
84
+ end
85
+
86
+ # Did each transition perform successfully? This will only be true if the
87
+ # following requirements are met:
88
+ # * No +before+ callbacks halt
89
+ # * All actions run successfully (always true if skipping actions)
90
+ def success?
91
+ @success
92
+ end
93
+
94
+ # Gets the object being transitioned
95
+ def object
96
+ first.object
97
+ end
98
+
99
+ # Gets the list of actions to run. If configured to skip actions, then
100
+ # this will return an empty collection.
101
+ def actions
102
+ empty? ? [nil] : map {|transition| transition.action}.uniq
103
+ end
104
+
105
+ # Determines whether an event attribute be used to trigger the transitions
106
+ # in this collection or whether the transitions be run directly *outside*
107
+ # of the action.
108
+ def use_event_attributes?
109
+ !skip_actions && !skip_after && actions.all? && actions.length == 1 && first.machine.action_helper_defined?
110
+ end
111
+
112
+ # Resets any information tracked from previous attempts to perform the
113
+ # collection
114
+ def reset
115
+ @results = {}
116
+ @success = false
117
+ end
118
+
119
+ # Runs each transition's callbacks recursively. Once all before callbacks
120
+ # have been executed, the transitions will then be persisted and the
121
+ # configured actions will be run.
122
+ #
123
+ # If any transition fails to run its callbacks, :halt will be thrown.
124
+ def run_callbacks(index = 0, &block)
125
+ if transition = self[index]
126
+ throw :halt unless transition.run_callbacks(:after => !skip_after) do
127
+ run_callbacks(index + 1, &block)
128
+ {:result => results[transition.action], :success => success?}
129
+ end
130
+ else
131
+ persist
132
+ run_actions(&block)
133
+ end
134
+ end
135
+
136
+ # Transitions the current value of the object's states to those specified by
137
+ # each transition
138
+ def persist
139
+ each {|transition| transition.persist}
140
+ end
141
+
142
+ # Runs the actions for each transition. If a block is given method, then it
143
+ # will be called instead of invoking each transition's action.
144
+ #
145
+ # The results of the actions will be used to determine #success?.
146
+ def run_actions
147
+ catch_exceptions do
148
+ @success = if block_given?
149
+ result = yield
150
+ actions.each {|action| results[action] = result}
151
+ !!result
152
+ else
153
+ actions.compact.each {|action| !skip_actions && results[action] = object.send(action)}
154
+ results.values.all?
155
+ end
156
+ end
157
+ end
158
+
159
+ # Rolls back changes made to the object's states via each transition
160
+ def rollback
161
+ each {|transition| transition.rollback}
162
+ end
163
+
164
+ # Wraps the given block with a rescue handler so that any exceptions that
165
+ # occur will automatically result in the transition rolling back any changes
166
+ # that were made to the object involved.
167
+ def catch_exceptions
168
+ begin
169
+ yield
170
+ rescue Exception
171
+ rollback
172
+ raise
173
+ end
174
+ end
175
+
176
+ # Runs a block within a transaction for the object being transitioned. If
177
+ # transactions are disabled, then this is a no-op.
178
+ def within_transaction
179
+ if use_transaction && !empty?
180
+ first.within_transaction do
181
+ yield
182
+ success?
183
+ end
184
+ else
185
+ yield
186
+ end
187
+ end
188
+ end
189
+
190
+ # Represents a collection of transitions that were generated from attribute-
191
+ # based events
192
+ class AttributeTransitionCollection < TransitionCollection
193
+ def initialize(transitions = [], options = {}) #:nodoc:
194
+ super(transitions, {:transaction => false, :actions => false}.merge(options))
195
+ end
196
+
197
+ private
198
+ # Hooks into running transition callbacks so that event / event transition
199
+ # attributes can be properly updated
200
+ def run_callbacks(index = 0)
201
+ if index == 0
202
+ # Clears any traces of the event attribute to prevent it from being
203
+ # evaluated multiple times if actions are nested
204
+ each do |transition|
205
+ transition.machine.write(object, :event, nil)
206
+ transition.machine.write(object, :event_transition, nil)
207
+ end
208
+
209
+ # Rollback only if exceptions occur during before callbacks
210
+ begin
211
+ super
212
+ rescue Exception
213
+ rollback unless @before_run
214
+ raise
215
+ end
216
+
217
+ # Persists transitions on the object if partial transition was successful.
218
+ # This allows us to reference them later to complete the transition with
219
+ # after callbacks.
220
+ each {|transition| transition.machine.write(object, :event_transition, transition)} if skip_after && success?
221
+ else
222
+ super
223
+ end
224
+ end
225
+
226
+ # Tracks that before callbacks have now completed
227
+ def persist
228
+ @before_run = true
229
+ super
230
+ end
231
+
232
+ # Resets callback tracking
233
+ def reset
234
+ super
235
+ @before_run = false
236
+ end
237
+
238
+ # Resets the event attribute so it can be re-evaluated if attempted again
239
+ def rollback
240
+ super
241
+ each {|transition| transition.machine.write(object, :event, transition.event) unless transition.transient?}
242
+ end
243
+ end
244
+ end