state_machine 0.8.1 → 0.9.0

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