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
@@ -64,6 +64,9 @@ module StateMachine
64
64
  # vehicle = Vehicle.create(:state_event => 'ignite') # => #<Vehicle @values={:state=>"idling", :name=>nil, :id=>1}>
65
65
  # vehicle.state # => "idling"
66
66
  #
67
+ # This technique is always used for transitioning states when the +save+
68
+ # action (which is the default) is configured for the machine.
69
+ #
67
70
  # === Security implications
68
71
  #
69
72
  # Beware that public event attributes mean that events can be fired
@@ -131,6 +134,9 @@ module StateMachine
131
134
  # end
132
135
  # end
133
136
  #
137
+ # If using the +save+ action for the machine, this option will be ignored as
138
+ # the transaction will be created by Sequel within +save+.
139
+ #
134
140
  # == Validation errors
135
141
  #
136
142
  # If an event fails to successfully fire because there are no matching
@@ -278,14 +284,34 @@ module StateMachine
278
284
  end
279
285
  end
280
286
 
281
- # Adds hooks into validation for automatically firing events
282
- def define_action_helpers
283
- if super && action == :save
284
- @instance_helper_module.class_eval do
285
- define_method(:valid?) do |*args|
286
- self.class.state_machines.fire_event_attributes(self, :save, false) { super(*args) }
287
+ # Adds hooks into validation for automatically firing events. This is
288
+ # a bit more complicated than other integrations since Sequel doesn't
289
+ # provide an easy way to hook around validation / save calls
290
+ def define_action_helpers
291
+ if action == :save
292
+ @instance_helper_module.class_eval do
293
+ define_method(:valid?) do |*args|
294
+ yielded = false
295
+ result = self.class.state_machines.transitions(self, :save, :after => false).perform do
296
+ yielded = true
297
+ super(*args)
298
+ end
299
+
300
+ raise_on_save_failure && !yielded && !result ? save_failure(:validation) : result
287
301
  end
288
- end
302
+
303
+ define_method(defined?(::Sequel::MAJOR) && (::Sequel::MAJOR >= 3 || ::Sequel::MAJOR == 2 && ::Sequel::MINOR == 12) ? :_save : :save) do |*args|
304
+ yielded = false
305
+ result = self.class.state_machines.transitions(self, :save).perform do
306
+ yielded = true
307
+ super(*args)
308
+ end
309
+
310
+ yielded || result ? result : save_failure(:save)
311
+ end
312
+ end unless owner_class.state_machines.any? {|name, machine| machine.action == :save && machine != self}
313
+ else
314
+ super
289
315
  end
290
316
  end
291
317
 
@@ -90,17 +90,17 @@ module StateMachine
90
90
  # action being invoked (and not a superclass), then it must manually run the
91
91
  # StateMachine hook that checks for event attributes.
92
92
  #
93
- # For example, in ActiveRecord, DataMapper, and Sequel, the default action
94
- # (+save+) is already defined in a base class. As a result, when a state
95
- # machine is defined in a model / resource, StateMachine can automatically
96
- # hook into the +save+ action.
93
+ # For example, in ActiveRecord, DataMapper, MongoMapper, and Sequel, the
94
+ # default action (+save+) is already defined in a base class. As a result,
95
+ # when a state machine is defined in a model / resource, StateMachine can
96
+ # automatically hook into the +save+ action.
97
97
  #
98
98
  # On the other hand, the Vehicle class from above defined its own +save+
99
99
  # method (and there is no +save+ method in its superclass). As a result, it
100
100
  # must be modified like so:
101
101
  #
102
102
  # def save
103
- # self.class.state_machines.fire_event_attributes(self, :save) do
103
+ # self.class.state_machines.transitions(self, :save).perform do
104
104
  # @saving_state = state
105
105
  # fail != true
106
106
  # end
@@ -156,6 +156,11 @@ module StateMachine
156
156
  # later callbacks are canceled. If an +after+ callback halts the chain,
157
157
  # the later callbacks are canceled, but the transition is still successful.
158
158
  #
159
+ # These same rules apply to +around+ callbacks with the exception that any
160
+ # +around+ callback that doesn't yield will essentially result in :halt being
161
+ # thrown. Any code executed after the yield will behave in the same way as
162
+ # +after+ callbacks.
163
+ #
159
164
  # *Note* that if a +before+ callback fails and the bang version of an event
160
165
  # was invoked, an exception will be raised instead of returning false. For
161
166
  # example,
@@ -207,6 +212,10 @@ module StateMachine
207
212
  # def self.after_transition(vehicle, transition)
208
213
  # logger.info "#{vehicle} instructed to #{transition.event}... #{transition.attribute} was: #{transition.from}, #{transition.attribute} is: #{transition.to}"
209
214
  # end
215
+ #
216
+ # def self.around_transition(vehicle, transition)
217
+ # logger.info Benchmark.measure { yield }
218
+ # end
210
219
  # end
211
220
  #
212
221
  # Vehicle.state_machine do
@@ -215,6 +224,8 @@ module StateMachine
215
224
  #
216
225
  # after_transition :on => :park, :do => VehicleObserver.method(:after_park)
217
226
  # after_transition VehicleObserver.method(:after_transition)
227
+ #
228
+ # around_transition VehicleObserver.method(:around_transition)
218
229
  # end
219
230
  #
220
231
  # One common callback is to record transitions for all models in the system
@@ -279,11 +290,13 @@ module StateMachine
279
290
  #
280
291
  # When a state machine is defined for classes using any of the above libraries,
281
292
  # it will try to automatically determine the integration to use (Agnostic,
282
- # ActiveRecord, DataMapper, or Sequel) based on the class definition. To
283
- # see how each integration affects the machine's behavior, refer to all
284
- # constants defined under the StateMachine::Integrations namespace.
293
+ # ActiveModel, ActiveRecord, DataMapper, MongoMapper, or Sequel) based on the
294
+ # class definition. To see how each integration affects the machine's
295
+ # behavior, refer to all constants defined under the StateMachine::Integrations
296
+ # namespace.
285
297
  class Machine
286
298
  include Assertions
299
+ include EvalHelpers
287
300
  include MatcherHelpers
288
301
 
289
302
  class << self
@@ -489,6 +502,12 @@ module StateMachine
489
502
  states.each {|state| state.initial = (state.name == @initial_state)}
490
503
  end
491
504
 
505
+ # Initializes the state on the given object. This will always write to the
506
+ # attribute regardless of whether a value is already present.
507
+ def initialize_state(object)
508
+ write(object, :state, initial_state(object).value)
509
+ end
510
+
492
511
  # Gets the actual name of the attribute on the machine's owner class that
493
512
  # stores data with the given name.
494
513
  def attribute(name = :state)
@@ -569,7 +588,7 @@ module StateMachine
569
588
  # vehicle.force_idle = false
570
589
  # Vehicle.state_machine.initial_state(vehicle) # => #<StateMachine::State name=:parked value="parked" initial=false>
571
590
  def initial_state(object)
572
- states.fetch(dynamic_initial_state? ? @initial_state.call(object) : @initial_state)
591
+ states.fetch(dynamic_initial_state? ? evaluate_method(object, @initial_state) : @initial_state)
573
592
  end
574
593
 
575
594
  # Whether a dynamic initial state is being used in the machine
@@ -917,6 +936,7 @@ module StateMachine
917
936
  #
918
937
  # event :first_gear do
919
938
  # transition :parked => :first_gear, :if => :seatbelt_on?
939
+ # transition :parked => same # Allow to loopback if seatbelt is off
920
940
  # end
921
941
  #
922
942
  # See StateMachine::Event#transition for more information on
@@ -983,6 +1003,7 @@ module StateMachine
983
1003
  #
984
1004
  # event :ignite do
985
1005
  # transition :parked => :idling
1006
+ # transition :idling => same # Allow ignite while still idling
986
1007
  # end
987
1008
  # end
988
1009
  # end
@@ -1084,15 +1105,20 @@ module StateMachine
1084
1105
  #
1085
1106
  # == Result requirements
1086
1107
  #
1087
- # By default, after_transition callbacks will only be run if the transition
1108
+ # By default, +after_transition+ callbacks and code executed after an
1109
+ # +around_transition+ callback yields will only be run if the transition
1088
1110
  # was performed successfully. A transition is successful if the machine's
1089
1111
  # action is not configured or does not return false when it is invoked.
1090
- # In order to include failed attempts when running an after_transition
1091
- # callback, the :include_failures option can be specified like so:
1112
+ # In order to include failed attempts when running an +after_transition+ or
1113
+ # +around_transition+ callback, the <tt>:include_failures</tt> option can be
1114
+ # specified like so:
1092
1115
  #
1093
1116
  # after_transition :include_failures => true, :do => ... # Runs on all attempts to transition, including failures
1094
1117
  # after_transition :do => ... # Runs only on successful attempts to transition
1095
1118
  #
1119
+ # around_transition :include_failures => true, :do => ... # Runs on all attempts to transition, including failures
1120
+ # around_transition :do => ... # Runs only on successful attempts to transition
1121
+ #
1096
1122
  # == Verbose Requirements
1097
1123
  #
1098
1124
  # Requirements can also be defined using verbose options rather than the
@@ -1203,6 +1229,67 @@ module StateMachine
1203
1229
  add_callback(:after, options, &block)
1204
1230
  end
1205
1231
 
1232
+ # Creates a callback that will be invoked *around* a transition so long as
1233
+ # the given requirements match the transition.
1234
+ #
1235
+ # == The callback
1236
+ #
1237
+ # Around callbacks wrap transitions, executing code both before and after.
1238
+ # These callbacks are defined in the exact same manner as before / after
1239
+ # callbacks with the exception that the transition must be yielded to in
1240
+ # order to finish running it.
1241
+ #
1242
+ # If defining +around+ callbacks using blocks, you must yield within the
1243
+ # transition by directly calling the block (since yielding is not allowed
1244
+ # within blocks).
1245
+ #
1246
+ # For example,
1247
+ #
1248
+ # class Vehicle
1249
+ # state_machine do
1250
+ # around_transition do |block|
1251
+ # Benchmark.measure { block.call }
1252
+ # end
1253
+ #
1254
+ # around_transition do |vehicle, block|
1255
+ # logger.info "vehicle was #{state}..."
1256
+ # block.call
1257
+ # logger.info "...and is now #{state}"
1258
+ # end
1259
+ #
1260
+ # around_transition do |vehicle, transition, block|
1261
+ # logger.info "before #{transition.event}: #{vehicle.state}"
1262
+ # block.call
1263
+ # logger.info "after #{transition.event}: #{vehicle.state}"
1264
+ # end
1265
+ # end
1266
+ # end
1267
+ #
1268
+ # Notice that referencing the block is similar to doing so within an
1269
+ # actual method definition in that it is always the last argument.
1270
+ #
1271
+ # On the other hand, if you're defining +around+ callbacks using method
1272
+ # references, you can yield like normal:
1273
+ #
1274
+ # class Vehicle
1275
+ # state_machine do
1276
+ # around_transition :benchmark
1277
+ # ...
1278
+ # end
1279
+ #
1280
+ # def benchmark
1281
+ # Benchmark.measure { yield }
1282
+ # end
1283
+ # end
1284
+ #
1285
+ # See +before_transition+ for a description of the possible configurations
1286
+ # for defining callbacks.
1287
+ def around_transition(*args, &block)
1288
+ options = (args.last.is_a?(Hash) ? args.pop : {})
1289
+ options[:do] = args if args.any?
1290
+ add_callback(:around, options, &block)
1291
+ end
1292
+
1206
1293
  # Marks the given object as invalid with the given message.
1207
1294
  #
1208
1295
  # By default, this is a no-op.
@@ -1265,6 +1352,7 @@ module StateMachine
1265
1352
  begin
1266
1353
  # Load the graphviz library
1267
1354
  require 'rubygems'
1355
+ gem 'ruby-graphviz', '>=0.9.0'
1268
1356
  require 'graphviz'
1269
1357
 
1270
1358
  graph = GraphViz.new('G', :rankdir => options[:orientation] == 'landscape' ? 'LR' : 'TB')
@@ -1284,7 +1372,7 @@ module StateMachine
1284
1372
  # Generate the graph
1285
1373
  graphvizVersion = Constants::RGV_VERSION.split('.')
1286
1374
 
1287
- if graphvizVersion[0] == '0' && (graphvizVersion[1] < '9' || graphvizVersion[1] == '9' && graphvizVersion[2] == '0')
1375
+ if graphvizVersion[1] == '9' && graphvizVersion[2] == '0'
1288
1376
  outputOptions = {
1289
1377
  :output => options[:format],
1290
1378
  :file => File.join(options[:path], "#{options[:name]}.#{options[:format]}")
@@ -1298,11 +1386,17 @@ module StateMachine
1298
1386
  graph.output(outputOptions)
1299
1387
  graph
1300
1388
  rescue LoadError
1301
- $stderr.puts 'Cannot draw the machine. `gem install ruby-graphviz` and try again.'
1389
+ $stderr.puts 'Cannot draw the machine. `gem install ruby-graphviz` >= v0.9.0 and try again.'
1302
1390
  false
1303
1391
  end
1304
1392
  end
1305
1393
 
1394
+ # Determines whether a helper method was defined for firing attribute-based
1395
+ # event transitions when the configuration action gets called.
1396
+ def action_helper_defined?
1397
+ @action_helper_defined
1398
+ end
1399
+
1306
1400
  protected
1307
1401
  # Runs additional initialization hooks. By default, this is a no-op.
1308
1402
  def after_initialize
@@ -1389,23 +1483,27 @@ module StateMachine
1389
1483
  # Adds helper methods for automatically firing events when an action
1390
1484
  # is invoked
1391
1485
  def define_action_helpers(action_hook = self.action)
1392
- action = self.action
1393
- private_method = owner_class.private_method_defined?(action_hook)
1486
+ private_action = owner_class.private_method_defined?(action_hook)
1487
+ action_defined = @action_helper_defined = owner_class.ancestors.any? do |ancestor|
1488
+ ancestor != owner_class && (ancestor.method_defined?(action_hook) || ancestor.private_method_defined?(action_hook))
1489
+ end
1490
+ action_overridden = owner_class.state_machines.any? {|name, machine| machine.action == action && machine != self}
1394
1491
 
1395
- if (owner_class.method_defined?(action_hook) || private_method) && !owner_class.state_machines.any? {|name, machine| machine.action == action && machine != self}
1396
- # Action is defined and hasn't already been overridden by another machine
1492
+ # Only define helper if:
1493
+ # 1. Action was originally defined somewhere other than the owner class
1494
+ # 2. It hasn't already been overridden by another machine
1495
+ if action_defined && !action_overridden
1496
+ action = self.action
1397
1497
  @instance_helper_module.class_eval do
1398
- # Override the default action to invoke the before / after hooks
1399
1498
  define_method(action_hook) do |*args|
1400
- self.class.state_machines.fire_event_attributes(self, action) { super(*args) }
1499
+ self.class.state_machines.transitions(self, action).perform { super(*args) }
1401
1500
  end
1402
1501
 
1403
- private action_hook if private_method
1502
+ private action_hook if private_action
1404
1503
  end
1405
1504
 
1406
1505
  true
1407
1506
  else
1408
- # Action already defined: don't add integration-specific hooks
1409
1507
  false
1410
1508
  end
1411
1509
  end
@@ -1466,7 +1564,7 @@ module StateMachine
1466
1564
 
1467
1565
  # Adds a new transition callback of the given type.
1468
1566
  def add_callback(type, options, &block)
1469
- callbacks[type] << callback = Callback.new(options, &block)
1567
+ callbacks[type == :around ? :before : type] << callback = Callback.new(type, options, &block)
1470
1568
  add_states(callback.known_states)
1471
1569
  callback
1472
1570
  end
@@ -6,13 +6,13 @@ module StateMachine
6
6
  # (which must mean the defaults are being skipped)
7
7
  def initialize_states(object, options = {})
8
8
  if ignore = options[:ignore]
9
- ignore.map! {|attribute| attribute.to_sym}
9
+ ignore = ignore.map {|attribute| attribute.to_sym}
10
10
  end
11
11
 
12
12
  each_value do |machine|
13
13
  if (!ignore || !ignore.include?(machine.attribute)) && (!options.include?(:dynamic) || machine.dynamic_initial_state? == options[:dynamic])
14
14
  value = machine.read(object, :state)
15
- machine.write(object, :state, machine.initial_state(object).value) if ignore || value.nil? || value.respond_to?(:empty?) && value.empty?
15
+ machine.initialize_state(object) if ignore || value.nil? || value.respond_to?(:empty?) && value.empty?
16
16
  end
17
17
  end
18
18
  end
@@ -26,9 +26,7 @@ module StateMachine
26
26
  transitions = events.collect do |event_name|
27
27
  # Find the actual event being run
28
28
  event = nil
29
- detect do |name, machine|
30
- event = machine.events[event_name, :qualified_name]
31
- end
29
+ detect {|name, machine| event = machine.events[event_name, :qualified_name]}
32
30
 
33
31
  raise InvalidEvent, "#{event_name.inspect} is an unknown state machine event" unless event
34
32
 
@@ -44,112 +42,23 @@ module StateMachine
44
42
  # Run the events in parallel only if valid transitions were found for
45
43
  # all of them
46
44
  if events.length == transitions.length
47
- Transition.perform_within_transaction(transitions, :action => run_action)
45
+ TransitionCollection.new(transitions, :actions => run_action).perform
48
46
  else
49
47
  false
50
48
  end
51
49
  end
52
50
 
53
- # Runs one or more event attributes in parallel during the invocation of
54
- # an action on the given object. after_transition callbacks can be
55
- # optionally disabled if the events are being only partially fired (for
56
- # example, when validating records in ORM integrations).
57
- #
58
- # The event attributes that will be fired are based on which machines
59
- # match the action that is being invoked.
60
- #
61
- # == Examples
62
- #
63
- # class Vehicle
64
- # include DataMapper::Resource
65
- # property :id, Serial
66
- #
67
- # state_machine :initial => :parked do
68
- # event :ignite do
69
- # transition :parked => :idling
70
- # end
71
- # end
72
- #
73
- # state_machine :alarm_state, :namespace => 'alarm', :initial => :active do
74
- # event :disable do
75
- # transition all => :off
76
- # end
77
- # end
78
- # end
79
- #
80
- # With valid events:
81
- #
82
- # vehicle = Vehicle.create # => #<Vehicle id=1 state="parked" alarm_state="active">
83
- # vehicle.state_event = 'ignite'
84
- # vehicle.alarm_state_event = 'disable'
85
- #
86
- # Vehicle.state_machines.fire_event_attributes(vehicle, :save) { true }
87
- # vehicle.state # => "idling"
88
- # vehicle.state_event # => nil
89
- # vehicle.alarm_state # => "off"
90
- # vehicle.alarm_state_event # => nil
91
- #
92
- # With invalid events:
93
- #
94
- # vehicle = Vehicle.create # => #<Vehicle id=1 state="parked" alarm_state="active">
95
- # vehicle.state_event = 'park'
96
- # vehicle.alarm_state_event = 'disable'
97
- #
98
- # Vehicle.state_machines.fire_event_attributes(vehicle, :save) { true }
99
- # vehicle.state # => "parked"
100
- # vehicle.state_event # => nil
101
- # vehicle.alarm_state # => "active"
102
- # vehicle.alarm_state_event # => nil
103
- # vehicle.errors # => #<DataMapper::Validate::ValidationErrors:0xb7af9abc @errors={"state_event"=>["is invalid"]}>
104
- #
105
- # With partial firing:
51
+ # Builds the collection of transitions for all event attributes defined on
52
+ # the given object. This will only include events whose machine actions
53
+ # match the one specified.
106
54
  #
107
- # vehicle = Vehicle.create # => #<Vehicle id=1 state="parked" alarm_state="active">
108
- # vehicle.state_event = 'ignite'
109
- #
110
- # Vehicle.state_machines.fire_event_attributes(vehicle, :save, false) { true }
111
- # vehicle.state # => "idling"
112
- # vehicle.state_event # => "ignite"
113
- # vehicle.state_event_transition # => #<StateMachine::Transition attribute=:state event=:ignite from="parked" from_name=:parked to="idling" to_name=:idling>
114
- def fire_event_attributes(object, action, complete = true)
115
- # Get the transitions to fire for each applicable machine
116
- transitions = map {|name, machine| machine.action == action ? machine.events.attribute_transition_for(object, true) : nil}.compact
117
- return yield if transitions.empty?
118
-
119
- # The value generated by the yielded block (the actual action)
120
- action_value = nil
121
-
122
- # Make sure all events were valid
123
- if result = transitions.all? {|transition| transition != false}
124
- # Clear any traces of the event since transitions are available and to
125
- # prevent from being evaluated multiple times if actions are nested
126
- transitions.each do |transition|
127
- transition.machine.write(object, :event, nil)
128
- transition.machine.write(object, :event_transition, nil)
129
- end
130
-
131
- # Perform the transitions
132
- begin
133
- result = Transition.perform(transitions, :after => complete) { action_value = yield }
134
- rescue Exception
135
- # Reset the event attribute so it can be re-evaluated if attempted again
136
- transitions.each do |transition|
137
- transition.machine.write(object, :event, transition.event)
138
- end
139
-
140
- raise
141
- end
142
-
143
- transitions.each do |transition|
144
- # Revert event if failed (to allow for more attempts)
145
- transition.machine.write(object, :event, transition.event) unless result
146
-
147
- # Track transition if partial transition was successful
148
- transition.machine.write(object, :event_transition, transition) if !complete && result
149
- end
55
+ # These should only be fired as a result of the action being run.
56
+ def transitions(object, action, options = {})
57
+ transitions = map do |name, machine|
58
+ machine.events.attribute_transition_for(object, true) if machine.action == action
150
59
  end
151
60
 
152
- action_value.nil? ? result : action_value
61
+ AttributeTransitionCollection.new(transitions.compact, options)
153
62
  end
154
63
  end
155
64
  end