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
@@ -50,15 +50,31 @@ module StateMachine
50
50
  # evaluate_method(person, lambda {|person| person.name}, 21) # => "John Smith"
51
51
  # evaluate_method(person, lambda {|person, age| "#{person.name} is #{age}"}, 21) # => "John Smith is 21"
52
52
  # evaluate_method(person, lambda {|person, age| "#{person.name} is #{age}"}, 21, 'male') # => ArgumentError: wrong number of arguments (3 for 2)
53
- def evaluate_method(object, method, *args)
53
+ def evaluate_method(object, method, *args, &block)
54
54
  case method
55
55
  when Symbol
56
- object.method(method).arity == 0 ? object.send(method) : object.send(method, *args)
56
+ object.method(method).arity == 0 ? object.send(method, &block) : object.send(method, *args, &block)
57
57
  when Proc, Method
58
58
  args.unshift(object)
59
- [0, 1].include?(method.arity) ? method.call(*args.slice(0, method.arity)) : method.call(*args)
59
+ arity = method.arity
60
+ limit = [0, 1].include?(arity) ? arity : args.length
61
+
62
+ # Procs don't support blocks in < Ruby 1.8.6, so it's tacked on as an
63
+ # argument for consistency across versions of Ruby (even though 1.9
64
+ # supports yielding within blocks)
65
+ if block_given? && Proc === method && arity != 0
66
+ if [1, 2].include?(arity)
67
+ limit = arity
68
+ args.insert(limit - 1, block)
69
+ else
70
+ limit += 1 unless limit < 0
71
+ args.push(block)
72
+ end
73
+ end
74
+
75
+ method.call(*args[0, limit], &block)
60
76
  when String
61
- eval(method, object.instance_eval {binding})
77
+ eval(method, object.instance_eval {binding}, &block)
62
78
  else
63
79
  raise ArgumentError, 'Methods must be a symbol denoting the method to call, a block to be invoked, or a string to be evaluated'
64
80
  end
@@ -0,0 +1,4 @@
1
+ # Load each application initializer
2
+ Dir["#{File.dirname(__FILE__)}/initializers/*.rb"].sort.each do |path|
3
+ require "state_machine/initializers/#{File.basename(path)}"
4
+ end
@@ -0,0 +1 @@
1
+ Merb::Plugins.add_rakefiles("#{File.dirname(__FILE__)}/../../tasks/state_machine") if defined?(Merb::Plugins)
@@ -0,0 +1,7 @@
1
+ class StateMachine::Railtie < Rails::Railtie
2
+ railtie_name :state_machine
3
+
4
+ rake_tasks do
5
+ load 'tasks/state_machine.rb'
6
+ end
7
+ end if defined?(Rails::Railtie)
@@ -32,21 +32,34 @@ module StateMachine
32
32
  # class Vehicle
33
33
  # end
34
34
  #
35
- # class ARVehicle < ActiveRecord::Base
35
+ # class ActiveModelVehicle
36
+ # include ActiveModel::Dirty
37
+ # include ActiveModel::Observing
38
+ # include ActiveModel::Validations
36
39
  # end
37
40
  #
38
- # class DMVehicle
41
+ # class ActiveRecordVehicle < ActiveRecord::Base
42
+ # end
43
+ #
44
+ # class DataMapperVehicle
39
45
  # include DataMapper::Resource
40
46
  # end
41
47
  #
48
+ # class MongoMapperVehicle
49
+ # include MongoMapper::Document
50
+ # end
51
+ #
42
52
  # class SequelVehicle < Sequel::Model
43
53
  # end
44
54
  #
45
- # StateMachine::Integrations.match(Vehicle) # => nil
46
- # StateMachine::Integrations.match(ARVehicle) # => StateMachine::Integrations::ActiveRecord
47
- # StateMachine::Integrations.match(DMVehicle) # => StateMachine::Integrations::DataMapper
48
- # StateMachine::Integrations.match(SequelVehicle) # => StateMachine::Integrations::Sequel
55
+ # StateMachine::Integrations.match(Vehicle) # => nil
56
+ # StateMachine::Integrations.match(ActiveModelVehicle) # => StateMachine::Integrations::ActiveModel
57
+ # StateMachine::Integrations.match(ActiveRecordVehicle) # => StateMachine::Integrations::ActiveRecord
58
+ # StateMachine::Integrations.match(DataMapperVehicle) # => StateMachine::Integrations::DataMapper
59
+ # StateMachine::Integrations.match(MongoMapperVehicle) # => StateMachine::Integrations::MongoMapper
60
+ # StateMachine::Integrations.match(SequelVehicle) # => StateMachine::Integrations::Sequel
49
61
  def self.match(klass)
62
+ constants = self.constants.map {|c| c.to_s}.select {|c| c != 'ActiveModel'}.sort << 'ActiveModel'
50
63
  if integration = constants.find {|name| const_get(name).matches?(klass)}
51
64
  find(integration)
52
65
  end
@@ -58,7 +71,9 @@ module StateMachine
58
71
  # == Examples
59
72
  #
60
73
  # StateMachine::Integrations.find(:active_record) # => StateMachine::Integrations::ActiveRecord
74
+ # StateMachine::Integrations.find(:active_model) # => StateMachine::Integrations::ActiveModel
61
75
  # StateMachine::Integrations.find(:data_mapper) # => StateMachine::Integrations::DataMapper
76
+ # StateMachine::Integrations.find(:mongo_mapper) # => StateMachine::Integrations::MongoMapper
62
77
  # StateMachine::Integrations.find(:sequel) # => StateMachine::Integrations::Sequel
63
78
  # StateMachine::Integrations.find(:invalid) # => NameError: wrong constant name Invalid
64
79
  def self.find(name)
@@ -0,0 +1,414 @@
1
+ module StateMachine
2
+ module Integrations #:nodoc:
3
+ # Adds support for integrating state machines with ActiveModel classes.
4
+ #
5
+ # == Examples
6
+ #
7
+ # If using ActiveModel directly within your class, then any one of the
8
+ # following features need to be included in order for the integration to be
9
+ # detected:
10
+ # * ActiveModel::Dirty
11
+ # * ActiveModel::Observing
12
+ # * ActiveModel::Validations
13
+ #
14
+ # Below is an example of a simple state machine defined within an
15
+ # ActiveModel class:
16
+ #
17
+ # class Vehicle
18
+ # include ActiveModel::Dirty
19
+ # include ActiveModel::Observing
20
+ # include ActiveModel::Validations
21
+ #
22
+ # attr_accessor :state
23
+ # define_attribute_methods [:state]
24
+ #
25
+ # state_machine :initial => :parked do
26
+ # event :ignite do
27
+ # transition :parked => :idling
28
+ # end
29
+ # end
30
+ # end
31
+ #
32
+ # The examples in the sections below will use the above class as a
33
+ # reference.
34
+ #
35
+ # == Actions
36
+ #
37
+ # By default, no action will be invoked when a state is transitioned. This
38
+ # means that if you want to save changes when transitioning, you must
39
+ # define the action yourself like so:
40
+ #
41
+ # class Vehicle
42
+ # include ActiveModel::Validations
43
+ # attr_accessor :state
44
+ #
45
+ # state_machine :action => :save do
46
+ # ...
47
+ # end
48
+ #
49
+ # def save
50
+ # # Save changes
51
+ # end
52
+ # end
53
+ #
54
+ # == Validation errors
55
+ #
56
+ # In order to hook in validation support for your model, the
57
+ # ActiveModel::Validations feature must be included. If this is included
58
+ # and an event fails to successfully fire because there are no matching
59
+ # transitions for the object, a validation error is added to the object's
60
+ # state attribute to help in determining why it failed.
61
+ #
62
+ # For example,
63
+ #
64
+ # vehicle = Vehicle.new
65
+ # vehicle.ignite # => false
66
+ # vehicle.errors.full_messages # => ["State cannot transition via \"ignite\""]
67
+ #
68
+ # == Callbacks
69
+ #
70
+ # All before/after transition callbacks defined for ActiveModel models
71
+ # behave in the same way that other ActiveSupport callbacks behave. The
72
+ # object involved in the transition is passed in as an argument.
73
+ #
74
+ # For example,
75
+ #
76
+ # class Vehicle
77
+ # include ActiveModel::Validations
78
+ # attr_accessor :state
79
+ #
80
+ # state_machine :initial => :parked do
81
+ # before_transition any => :idling do |vehicle|
82
+ # vehicle.put_on_seatbelt
83
+ # end
84
+ #
85
+ # before_transition do |vehicle, transition|
86
+ # # log message
87
+ # end
88
+ #
89
+ # event :ignite do
90
+ # transition :parked => :idling
91
+ # end
92
+ # end
93
+ #
94
+ # def put_on_seatbelt
95
+ # ...
96
+ # end
97
+ # end
98
+ #
99
+ # Note, also, that the transition can be accessed by simply defining
100
+ # additional arguments in the callback block.
101
+ #
102
+ # == Observers
103
+ #
104
+ # In order to hook in observer support for your application, the
105
+ # ActiveModel::Observing feature must be included. Because of the way
106
+ # ActiveModel observers are designed, there is less flexibility around the
107
+ # specific transitions that can be hooked in. However, a large number of
108
+ # hooks *are* supported. For example, if a transition for a object's
109
+ # +state+ attribute changes the state from +parked+ to +idling+ via the
110
+ # +ignite+ event, the following observer methods are supported:
111
+ # * before/after_ignite_from_parked_to_idling
112
+ # * before/after_ignite_from_parked
113
+ # * before/after_ignite_to_idling
114
+ # * before/after_ignite
115
+ # * before/after_transition_state_from_parked_to_idling
116
+ # * before/after_transition_state_from_parked
117
+ # * before/after_transition_state_to_idling
118
+ # * before/after_transition_state
119
+ # * before/after_transition
120
+ #
121
+ # The following class shows an example of some of these hooks:
122
+ #
123
+ # class VehicleObserver < ActiveModel::Observer
124
+ # # Callback for :ignite event *before* the transition is performed
125
+ # def before_ignite(vehicle, transition)
126
+ # # log message
127
+ # end
128
+ #
129
+ # # Callback for :ignite event *after* the transition has been performed
130
+ # def after_ignite(vehicle, transition)
131
+ # # put on seatbelt
132
+ # end
133
+ #
134
+ # # Generic transition callback *before* the transition is performed
135
+ # def after_transition(vehicle, transition)
136
+ # Audit.log(vehicle, transition)
137
+ # end
138
+ # end
139
+ #
140
+ # More flexible transition callbacks can be defined directly within the
141
+ # model as described in StateMachine::Machine#before_transition
142
+ # and StateMachine::Machine#after_transition.
143
+ #
144
+ # To define a single observer for multiple state machines:
145
+ #
146
+ # class StateMachineObserver < ActiveModel::Observer
147
+ # observe Vehicle, Switch, Project
148
+ #
149
+ # def after_transition(object, transition)
150
+ # Audit.log(object, transition)
151
+ # end
152
+ # end
153
+ #
154
+ # == Dirty Attribute Tracking
155
+ #
156
+ # In order to hook in validation support for your model, the
157
+ # ActiveModel::Validations feature must be included. If this is included
158
+ # then state attributes will always be properly marked as changed whether
159
+ # they were a callback or not.
160
+ #
161
+ # For example,
162
+ #
163
+ # class Vehicle
164
+ # include ActiveModel::Dirty
165
+ # attr_accessor :state
166
+ #
167
+ # state_machine :initial => :parked do
168
+ # event :park do
169
+ # transition :parked => :parked
170
+ # end
171
+ # end
172
+ # end
173
+ #
174
+ # vehicle = Vehicle.new
175
+ # vehicle.changed # => []
176
+ # vehicle.park # => true
177
+ # vehicle.changed # => ["state"]
178
+ #
179
+ # == Creating new integrations
180
+ #
181
+ # If you want to integrate state_machine with an ORM that implements parts
182
+ # or all of the ActiveModel API, the following features must be specified:
183
+ # * i18n scope (locale)
184
+ # * Machine defaults
185
+ #
186
+ # For example,
187
+ #
188
+ # module StateMachine::Integrations::MyORM
189
+ # include StateMachine::Integrations::ActiveModel
190
+ #
191
+ # @defaults = {:action = > :persist}
192
+ #
193
+ # def self.matches?(klass)
194
+ # defined?(::MyORM::Base) && klass <= ::MyORM::Base
195
+ # end
196
+ #
197
+ # def self.extended(base)
198
+ # locale = "#{File.dirname(__FILE__)}/my_orm/locale.rb"
199
+ # I18n.load_path << locale unless I18n.load_path.include?(locale)
200
+ # end
201
+ #
202
+ # protected
203
+ # def runs_validation_on_action?
204
+ # action == :persist
205
+ # end
206
+ #
207
+ # def i18n_scope
208
+ # :myorm
209
+ # end
210
+ # end
211
+ #
212
+ # If you wish to implement other features, such as attribute initialization
213
+ # with protected attributes, named scopes, or database transactions, you
214
+ # must add these independent of the ActiveModel integration. See the
215
+ # ActiveRecord implementation for examples of these customizations.
216
+ module ActiveModel
217
+ module ClassMethods
218
+ # The default options to use for state machines using this integration
219
+ attr_reader :defaults
220
+
221
+ # Loads additional files specific to ActiveModel
222
+ def extended(base) #:nodoc:
223
+ require 'state_machine/integrations/active_model/observer'
224
+
225
+ if Object.const_defined?(:I18n)
226
+ locale = "#{File.dirname(__FILE__)}/active_model/locale.rb"
227
+ I18n.load_path << locale unless I18n.load_path.include?(locale)
228
+ end
229
+ end
230
+ end
231
+
232
+ def self.included(base) #:nodoc:
233
+ base.class_eval do
234
+ extend ClassMethods
235
+ end
236
+ end
237
+
238
+ extend ClassMethods
239
+
240
+ # Should this integration be used for state machines in the given class?
241
+ # Classes that include ActiveModel::Dirty, ActiveModel::Observing, or
242
+ # ActiveModel::Validations will automatically use the ActiveModel
243
+ # integration.
244
+ def self.matches?(klass)
245
+ features = %w(Dirty Observing Validations)
246
+ defined?(::ActiveModel) && features.any? {|feature| ::ActiveModel.const_defined?(feature) && klass <= ::ActiveModel.const_get(feature)}
247
+ end
248
+
249
+ @defaults = {}
250
+
251
+ # Forces the change in state to be recognized regardless of whether the
252
+ # state value actually changed
253
+ def write(object, attribute, value)
254
+ result = super
255
+ if attribute == :state && supports_dirty_tracking?(object) && !object.send("#{self.attribute}_changed?")
256
+ object.send("#{self.attribute}_will_change!")
257
+ end
258
+ result
259
+ end
260
+
261
+ # Adds a validation error to the given object
262
+ def invalidate(object, attribute, message, values = [])
263
+ if supports_validations?
264
+ attribute = self.attribute(attribute)
265
+ ancestors = ancestors_for(object.class)
266
+
267
+ options = values.inject({}) do |options, (key, value)|
268
+ # Generate all possible translation keys
269
+ group = key.to_s.pluralize
270
+ translations = ancestors.map {|ancestor| :"#{ancestor.model_name.underscore}.#{name}.#{group}.#{value}"}
271
+ translations.concat([:"#{name}.#{group}.#{value}", :"#{group}.#{value}", value.to_s])
272
+
273
+ options[key] = I18n.translate(translations.shift, :default => translations, :scope => [i18n_scope, :state_machines])
274
+ options
275
+ end
276
+
277
+ object.errors.add(attribute, message, options.merge(:default => @messages[message]))
278
+ end
279
+ end
280
+
281
+ # Resets any errors previously added when invalidating the given object
282
+ def reset(object)
283
+ object.errors.clear if supports_validations?
284
+ end
285
+
286
+ protected
287
+ # Whether observers are supported in the integration. Only true if
288
+ # ActiveModel::Observer is available.
289
+ def supports_observers?
290
+ defined?(::ActiveModel::Observing) && owner_class <= ::ActiveModel::Observing
291
+ end
292
+
293
+ # Whether validations are supported in the integration. Only true if
294
+ # the ActiveModel feature is enabled on the owner class.
295
+ def supports_validations?
296
+ defined?(::ActiveModel::Validations) && owner_class <= ::ActiveModel::Validations
297
+ end
298
+
299
+ # Do validations run when the action configured this machine is
300
+ # invoked? This is used to determine whether to fire off attribute-based
301
+ # event transitions when the action is run.
302
+ def runs_validations_on_action?
303
+ false
304
+ end
305
+
306
+ # Whether change (dirty) tracking is supported in the integration.
307
+ # Only true if the ActiveModel feature is enabled on the owner class.
308
+ def supports_dirty_tracking?(object)
309
+ defined?(::ActiveModel::Dirty) && owner_class <= ::ActiveModel::Dirty && object.respond_to?("#{self.attribute}_changed?")
310
+ end
311
+
312
+ # Determines the base scope to use when looking up translations
313
+ def i18n_scope
314
+ owner_class.i18n_scope
315
+ end
316
+
317
+ # Build a list of ancestors for the given class to use when
318
+ # determining which localization key to use for a particular string.
319
+ def ancestors_for(klass)
320
+ klass.lookup_ancestors
321
+ end
322
+
323
+ # Gets the terminator to use for callbacks
324
+ def callback_terminator
325
+ @terminator ||= lambda {|result| result == false}
326
+ end
327
+
328
+ # Adds the default callbacks for notifying ActiveModel observers
329
+ # before/after a transition has been performed.
330
+ def after_initialize
331
+ if supports_observers?
332
+ callbacks[:before] << Callback.new(:before) {|object, transition| notify(:before, object, transition)}
333
+ callbacks[:after] << Callback.new(:after) {|object, transition| notify(:after, object, transition)}
334
+ end
335
+ end
336
+
337
+ # Skips defining reader/writer methods since this is done automatically
338
+ def define_state_accessor
339
+ name = self.name
340
+
341
+ owner_class.validates_each(attribute) do |object, attr, value|
342
+ machine = object.class.state_machine(name)
343
+ machine.invalidate(object, :state, :invalid) unless machine.states.match(object)
344
+ end if supports_validations?
345
+ end
346
+
347
+ # Adds hooks into validation for automatically firing events
348
+ def define_action_helpers(*args)
349
+ super
350
+
351
+ action = self.action
352
+ @instance_helper_module.class_eval do
353
+ define_method(:valid?) do |*args|
354
+ self.class.state_machines.transitions(self, action, :after => false).perform { super(*args) }
355
+ end
356
+ end if runs_validations_on_action?
357
+ end
358
+
359
+ # Creates a new callback in the callback chain, always inserting it
360
+ # before the default Observer callbacks that were created after
361
+ # initialization.
362
+ def add_callback(type, options, &block)
363
+ options[:terminator] = callback_terminator
364
+
365
+ if supports_observers?
366
+ @callbacks[type == :around ? :before : type].insert(-2, callback = Callback.new(type, options, &block))
367
+ add_states(callback.known_states)
368
+ callback
369
+ else
370
+ super
371
+ end
372
+ end
373
+
374
+ private
375
+ # Notifies observers on the given object that a callback occurred
376
+ # involving the given transition. This will attempt to call the
377
+ # following methods on observers:
378
+ # * #{type}_#{qualified_event}_from_#{from}_to_#{to}
379
+ # * #{type}_#{qualified_event}_from_#{from}
380
+ # * #{type}_#{qualified_event}_to_#{to}
381
+ # * #{type}_#{qualified_event}
382
+ # * #{type}_transition_#{machine_name}_from_#{from}_to_#{to}
383
+ # * #{type}_transition_#{machine_name}_from_#{from}
384
+ # * #{type}_transition_#{machine_name}_to_#{to}
385
+ # * #{type}_transition_#{machine_name}
386
+ # * #{type}_transition
387
+ #
388
+ # This will always return true regardless of the results of the
389
+ # callbacks.
390
+ def notify(type, object, transition)
391
+ name = self.name
392
+ event = transition.qualified_event
393
+ from = transition.from_name
394
+ to = transition.to_name
395
+
396
+ # Machine-specific updates
397
+ ["#{type}_#{event}", "#{type}_transition_#{name}"].each do |event_segment|
398
+ ["_from_#{from}", nil].each do |from_segment|
399
+ ["_to_#{to}", nil].each do |to_segment|
400
+ object.class.changed
401
+ object.class.notify_observers([event_segment, from_segment, to_segment].join, object, transition)
402
+ end
403
+ end
404
+ end
405
+
406
+ # Generic updates
407
+ object.class.changed
408
+ object.class.notify_observers("#{type}_transition", object, transition)
409
+
410
+ true
411
+ end
412
+ end
413
+ end
414
+ end