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
@@ -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