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.
- data/CHANGELOG.rdoc +17 -0
- data/LICENSE +1 -1
- data/README.rdoc +162 -23
- data/Rakefile +3 -18
- data/lib/state_machine.rb +3 -4
- data/lib/state_machine/callback.rb +65 -13
- data/lib/state_machine/eval_helpers.rb +20 -4
- data/lib/state_machine/initializers.rb +4 -0
- data/lib/state_machine/initializers/merb.rb +1 -0
- data/lib/state_machine/initializers/rails.rb +7 -0
- data/lib/state_machine/integrations.rb +21 -6
- data/lib/state_machine/integrations/active_model.rb +414 -0
- data/lib/state_machine/integrations/active_model/locale.rb +11 -0
- data/lib/state_machine/integrations/{active_record → active_model}/observer.rb +7 -7
- data/lib/state_machine/integrations/active_record.rb +65 -129
- data/lib/state_machine/integrations/active_record/locale.rb +4 -11
- data/lib/state_machine/integrations/data_mapper.rb +24 -6
- data/lib/state_machine/integrations/data_mapper/observer.rb +36 -0
- data/lib/state_machine/integrations/mongo_mapper.rb +295 -0
- data/lib/state_machine/integrations/sequel.rb +33 -7
- data/lib/state_machine/machine.rb +121 -23
- data/lib/state_machine/machine_collection.rb +12 -103
- data/lib/state_machine/transition.rb +125 -164
- data/lib/state_machine/transition_collection.rb +244 -0
- data/lib/tasks/state_machine.rb +12 -15
- data/test/functional/state_machine_test.rb +11 -1
- data/test/unit/callback_test.rb +305 -32
- data/test/unit/eval_helpers_test.rb +103 -1
- data/test/unit/event_test.rb +2 -1
- data/test/unit/guard_test.rb +2 -1
- data/test/unit/integrations/active_model_test.rb +909 -0
- data/test/unit/integrations/active_record_test.rb +1542 -1292
- data/test/unit/integrations/data_mapper_test.rb +1369 -1041
- data/test/unit/integrations/mongo_mapper_test.rb +1349 -0
- data/test/unit/integrations/sequel_test.rb +1214 -985
- data/test/unit/integrations_test.rb +8 -0
- data/test/unit/machine_collection_test.rb +140 -513
- data/test/unit/machine_test.rb +212 -10
- data/test/unit/state_test.rb +2 -1
- data/test/unit/transition_collection_test.rb +2098 -0
- data/test/unit/transition_test.rb +704 -552
- metadata +16 -3
@@ -0,0 +1,11 @@
|
|
1
|
+
{:en => {
|
2
|
+
:activemodel => {
|
3
|
+
:errors => {
|
4
|
+
:messages => {
|
5
|
+
:invalid => StateMachine::Machine.default_messages[:invalid],
|
6
|
+
:invalid_event => StateMachine::Machine.default_messages[:invalid_event] % ['{{state}}'],
|
7
|
+
:invalid_transition => StateMachine::Machine.default_messages[:invalid_transition] % ['{{event}}']
|
8
|
+
}
|
9
|
+
}
|
10
|
+
}
|
11
|
+
}}
|
@@ -1,14 +1,14 @@
|
|
1
1
|
module StateMachine
|
2
2
|
module Integrations #:nodoc:
|
3
|
-
module
|
4
|
-
# Adds support for invoking callbacks on
|
3
|
+
module ActiveModel
|
4
|
+
# Adds support for invoking callbacks on ActiveModel observers with more
|
5
5
|
# than one argument (e.g. the record *and* the state transition). By
|
6
|
-
# default,
|
6
|
+
# default, ActiveModel only supports passing the record into the
|
7
7
|
# callbacks.
|
8
8
|
#
|
9
9
|
# For example:
|
10
10
|
#
|
11
|
-
# class VehicleObserver <
|
11
|
+
# class VehicleObserver < ActiveModel::Observer
|
12
12
|
# # The default behavior: only pass in the record
|
13
13
|
# def after_save(vehicle)
|
14
14
|
# end
|
@@ -40,6 +40,6 @@ module StateMachine
|
|
40
40
|
end
|
41
41
|
end
|
42
42
|
|
43
|
-
|
44
|
-
include StateMachine::Integrations::
|
45
|
-
end
|
43
|
+
ActiveModel::Observer.class_eval do
|
44
|
+
include StateMachine::Integrations::ActiveModel::Observer
|
45
|
+
end if defined?(ActiveModel::Observer)
|
@@ -64,6 +64,9 @@ module StateMachine
|
|
64
64
|
# vehicle = Vehicle.create(:state_event => 'ignite') # => #<Vehicle id: 1, name: nil, state: "idling">
|
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 ActiveRecord within +save+.
|
139
|
+
#
|
134
140
|
# == Validation errors
|
135
141
|
#
|
136
142
|
# If an event fails to successfully fire because there are no matching
|
@@ -304,8 +310,9 @@ module StateMachine
|
|
304
310
|
# events:
|
305
311
|
# park: 'estacionarse'
|
306
312
|
module ActiveRecord
|
313
|
+
include ActiveModel
|
314
|
+
|
307
315
|
# The default options to use for state machines using this integration
|
308
|
-
class << self; attr_reader :defaults; end
|
309
316
|
@defaults = {:action => :save}
|
310
317
|
|
311
318
|
# Should this integration be used for state machines in the given class?
|
@@ -315,9 +322,12 @@ module StateMachine
|
|
315
322
|
defined?(::ActiveRecord::Base) && klass <= ::ActiveRecord::Base
|
316
323
|
end
|
317
324
|
|
318
|
-
# Loads additional files specific to ActiveRecord
|
319
325
|
def self.extended(base) #:nodoc:
|
320
|
-
require 'state_machine/integrations/
|
326
|
+
require 'state_machine/integrations/active_model/observer'
|
327
|
+
|
328
|
+
::ActiveRecord::Observer.class_eval do
|
329
|
+
include StateMachine::Integrations::ActiveModel::Observer
|
330
|
+
end unless ::ActiveRecord::Observer.included_modules.include?(StateMachine::Integrations::ActiveModel::Observer)
|
321
331
|
|
322
332
|
if Object.const_defined?(:I18n)
|
323
333
|
locale = "#{File.dirname(__FILE__)}/active_record/locale.rb"
|
@@ -325,57 +335,53 @@ module StateMachine
|
|
325
335
|
end
|
326
336
|
end
|
327
337
|
|
328
|
-
# Forces the change in state to be recognized regardless of whether the
|
329
|
-
# state value actually changed
|
330
|
-
def write(object, attribute, value)
|
331
|
-
result = super
|
332
|
-
if attribute == :state && object.respond_to?("#{self.attribute}_will_change!") && !object.send("#{self.attribute}_changed?")
|
333
|
-
object.send("#{self.attribute}_will_change!")
|
334
|
-
end
|
335
|
-
result
|
336
|
-
end
|
337
|
-
|
338
338
|
# Adds a validation error to the given object
|
339
339
|
def invalidate(object, attribute, message, values = [])
|
340
|
-
attribute = self.attribute(attribute)
|
341
|
-
|
342
340
|
if Object.const_defined?(:I18n)
|
343
|
-
|
344
|
-
if ::ActiveRecord::VERSION::MAJOR >= 3
|
345
|
-
object.class.lookup_ancestors
|
346
|
-
elsif ::ActiveRecord::VERSION::MINOR == 3 && ::ActiveRecord::VERSION::TINY >= 2
|
347
|
-
object.class.self_and_descendants_from_active_record
|
348
|
-
else
|
349
|
-
object.class.self_and_descendents_from_active_record
|
350
|
-
end
|
351
|
-
|
352
|
-
options = values.inject({}) do |options, (key, value)|
|
353
|
-
# Generate all possible translation keys
|
354
|
-
group = key.to_s.pluralize
|
355
|
-
translations = klasses.map {|klass| :"#{klass.model_name.underscore}.#{name}.#{group}.#{value}"}
|
356
|
-
translations.concat([:"#{name}.#{group}.#{value}", :"#{group}.#{value}", value.to_s])
|
357
|
-
|
358
|
-
options[key] = I18n.translate(translations.shift, :default => translations, :scope => [:activerecord, :state_machines])
|
359
|
-
options
|
360
|
-
end
|
361
|
-
|
362
|
-
object.errors.add(attribute, message, options.merge(:default => @messages[message]))
|
341
|
+
super
|
363
342
|
else
|
364
|
-
object.errors.add(attribute, generate_message(message, values))
|
343
|
+
object.errors.add(self.attribute(attribute), generate_message(message, values))
|
365
344
|
end
|
366
345
|
end
|
367
346
|
|
368
|
-
# Resets any errors previously added when invalidating the given object
|
369
|
-
def reset(object)
|
370
|
-
object.errors.clear
|
371
|
-
end
|
372
|
-
|
373
347
|
protected
|
374
|
-
#
|
375
|
-
|
376
|
-
|
377
|
-
|
378
|
-
|
348
|
+
# Always adds observer support
|
349
|
+
def supports_observers?
|
350
|
+
true
|
351
|
+
end
|
352
|
+
|
353
|
+
# Always adds validation support
|
354
|
+
def supports_validations?
|
355
|
+
true
|
356
|
+
end
|
357
|
+
|
358
|
+
# Only runs validations on the action if using <tt>:save</tt>
|
359
|
+
def runs_validations_on_action?
|
360
|
+
action == :save
|
361
|
+
end
|
362
|
+
|
363
|
+
# Only adds dirty tracking support if ActiveRecord supports it
|
364
|
+
def supports_dirty_tracking?(object)
|
365
|
+
defined?(::ActiveRecord::Dirty) && object.respond_to?("#{self.attribute}_changed?") || super
|
366
|
+
end
|
367
|
+
|
368
|
+
# Always uses the <tt>:activerecord</tt> translation scope
|
369
|
+
def i18n_scope
|
370
|
+
:activerecord
|
371
|
+
end
|
372
|
+
|
373
|
+
# Attempts to look up a class's ancestors via:
|
374
|
+
# * #lookup_ancestors
|
375
|
+
# * #self_and_descendants_from_active_record
|
376
|
+
# * #self_and_descendents_from_active_record
|
377
|
+
def ancestors_for(klass)
|
378
|
+
if ::ActiveRecord::VERSION::MAJOR >= 3
|
379
|
+
super
|
380
|
+
elsif ::ActiveRecord::VERSION::MINOR == 3 && ::ActiveRecord::VERSION::TINY >= 2
|
381
|
+
klass.self_and_descendants_from_active_record
|
382
|
+
else
|
383
|
+
klass.self_and_descendents_from_active_record
|
384
|
+
end
|
379
385
|
end
|
380
386
|
|
381
387
|
# Defines an initialization hook into the owner class for setting the
|
@@ -414,16 +420,6 @@ module StateMachine
|
|
414
420
|
end_eval
|
415
421
|
end
|
416
422
|
|
417
|
-
# Skips defining reader/writer methods since this is done automatically
|
418
|
-
def define_state_accessor
|
419
|
-
name = self.name
|
420
|
-
|
421
|
-
owner_class.validates_each(attribute) do |record, attr, value|
|
422
|
-
machine = record.class.state_machine(name)
|
423
|
-
machine.invalidate(record, :state, :invalid) unless machine.states.match(record)
|
424
|
-
end
|
425
|
-
end
|
426
|
-
|
427
423
|
# Adds support for defining the attribute predicate, while providing
|
428
424
|
# compatibility with the default predicate which determines whether
|
429
425
|
# *anything* is set for the attribute's value
|
@@ -441,17 +437,7 @@ module StateMachine
|
|
441
437
|
|
442
438
|
# Adds hooks into validation for automatically firing events
|
443
439
|
def define_action_helpers
|
444
|
-
|
445
|
-
if super(:create_or_update)
|
446
|
-
@instance_helper_module.class_eval do
|
447
|
-
define_method(:valid?) do |*args|
|
448
|
-
self.class.state_machines.fire_event_attributes(self, :save, false) { super(*args) }
|
449
|
-
end
|
450
|
-
end
|
451
|
-
end
|
452
|
-
else
|
453
|
-
super
|
454
|
-
end
|
440
|
+
super(action == :save ? :create_or_update : action)
|
455
441
|
end
|
456
442
|
|
457
443
|
# Creates a scope for finding records *with* a particular state or
|
@@ -476,83 +462,33 @@ module StateMachine
|
|
476
462
|
object.class.transaction {raise ::ActiveRecord::Rollback unless yield}
|
477
463
|
end
|
478
464
|
|
479
|
-
# Creates a new callback in the callback chain, always inserting it
|
480
|
-
# before the default Observer callbacks that were created after
|
481
|
-
# initialization.
|
482
|
-
def add_callback(type, options, &block)
|
483
|
-
options[:terminator] = @terminator ||= lambda {|result| result == false}
|
484
|
-
@callbacks[type].insert(-2, callback = Callback.new(options, &block))
|
485
|
-
add_states(callback.known_states)
|
486
|
-
|
487
|
-
callback
|
488
|
-
end
|
489
|
-
|
490
465
|
private
|
491
|
-
# Defines a new named scope with the given name
|
492
|
-
# does not allow direct access to the model being used within the
|
493
|
-
# evaluation of a dynamic named scope, the scope must be generated
|
494
|
-
# manually. It's necessary to have access to the model so that the
|
495
|
-
# state names can be translated to their associated values and so that
|
496
|
-
# inheritance is respected properly.
|
466
|
+
# Defines a new named scope with the given name
|
497
467
|
def define_scope(name, scope)
|
498
|
-
if ::ActiveRecord::VERSION::MAJOR
|
468
|
+
if ::ActiveRecord::VERSION::MAJOR >= 3
|
469
|
+
lambda {|model, values| model.where(scope.call(values)[:conditions])}
|
470
|
+
else
|
499
471
|
if owner_class.respond_to?(:named_scope)
|
500
472
|
name = name.to_sym
|
501
473
|
machine_name = self.name
|
502
474
|
|
503
|
-
#
|
475
|
+
# Since ActiveRecord does not allow direct access to the model
|
476
|
+
# being used within the evaluation of a dynamic named scope, the
|
477
|
+
# scope must be generated manually. It's necessary to have access
|
478
|
+
# to the model so that the state names can be translated to their
|
479
|
+
# associated values and so that inheritance is respected properly.
|
504
480
|
owner_class.named_scope(name)
|
505
|
-
owner_class.scopes[name] = lambda do |
|
506
|
-
machine_states =
|
481
|
+
owner_class.scopes[name] = lambda do |model, *states|
|
482
|
+
machine_states = model.state_machine(machine_name).states
|
507
483
|
values = states.flatten.map {|state| machine_states.fetch(state).value}
|
508
484
|
|
509
|
-
::ActiveRecord::NamedScope::Scope.new(
|
485
|
+
::ActiveRecord::NamedScope::Scope.new(model, scope.call(values))
|
510
486
|
end
|
511
487
|
end
|
512
488
|
|
513
489
|
# Prevent the Machine class from wrapping the scope
|
514
490
|
false
|
515
|
-
else
|
516
|
-
lambda {|klass, values| klass.where(scope.call(values)[:conditions])}
|
517
|
-
end
|
518
|
-
end
|
519
|
-
|
520
|
-
# Notifies observers on the given object that a callback occurred
|
521
|
-
# involving the given transition. This will attempt to call the
|
522
|
-
# following methods on observers:
|
523
|
-
# * #{type}_#{qualified_event}_from_#{from}_to_#{to}
|
524
|
-
# * #{type}_#{qualified_event}_from_#{from}
|
525
|
-
# * #{type}_#{qualified_event}_to_#{to}
|
526
|
-
# * #{type}_#{qualified_event}
|
527
|
-
# * #{type}_transition_#{machine_name}_from_#{from}_to_#{to}
|
528
|
-
# * #{type}_transition_#{machine_name}_from_#{from}
|
529
|
-
# * #{type}_transition_#{machine_name}_to_#{to}
|
530
|
-
# * #{type}_transition_#{machine_name}
|
531
|
-
# * #{type}_transition
|
532
|
-
#
|
533
|
-
# This will always return true regardless of the results of the
|
534
|
-
# callbacks.
|
535
|
-
def notify(type, object, transition)
|
536
|
-
name = self.name
|
537
|
-
event = transition.qualified_event
|
538
|
-
from = transition.from_name
|
539
|
-
to = transition.to_name
|
540
|
-
|
541
|
-
# Machine-specific updates
|
542
|
-
["#{type}_#{event}", "#{type}_transition_#{name}"].each do |event_segment|
|
543
|
-
["_from_#{from}", nil].each do |from_segment|
|
544
|
-
["_to_#{to}", nil].each do |to_segment|
|
545
|
-
object.class.changed
|
546
|
-
object.class.notify_observers([event_segment, from_segment, to_segment].join, object, transition)
|
547
|
-
end
|
548
|
-
end
|
549
491
|
end
|
550
|
-
|
551
|
-
# Generic updates
|
552
|
-
object.class.changed
|
553
|
-
object.class.notify_observers("#{type}_transition", object, transition)
|
554
|
-
|
555
|
-
true
|
556
492
|
end
|
557
493
|
end
|
558
494
|
end
|
@@ -1,11 +1,4 @@
|
|
1
|
-
|
2
|
-
|
3
|
-
|
4
|
-
|
5
|
-
:invalid => StateMachine::Machine.default_messages[:invalid],
|
6
|
-
:invalid_event => StateMachine::Machine.default_messages[:invalid_event] % ['{{state}}'],
|
7
|
-
:invalid_transition => StateMachine::Machine.default_messages[:invalid_transition] % ['{{event}}']
|
8
|
-
}
|
9
|
-
}
|
10
|
-
}
|
11
|
-
}}
|
1
|
+
filename = "#{File.dirname(__FILE__)}/../active_model/locale.rb"
|
2
|
+
translations = eval(IO.read(filename), binding, filename)
|
3
|
+
translations[:en][:activerecord] = translations[:en].delete(:activemodel)
|
4
|
+
translations
|
@@ -70,6 +70,9 @@ module StateMachine
|
|
70
70
|
# vehicle = Vehicle.create(:state_event => 'ignite') # => #<Vehicle id=1 name=nil state="idling">
|
71
71
|
# vehicle.state # => "idling"
|
72
72
|
#
|
73
|
+
# This technique is always used for transitioning states when the +save+
|
74
|
+
# action (which is the default) is configured for the machine.
|
75
|
+
#
|
73
76
|
# === Security implications
|
74
77
|
#
|
75
78
|
# Beware that public event attributes mean that events can be fired
|
@@ -140,6 +143,10 @@ module StateMachine
|
|
140
143
|
# end
|
141
144
|
# end
|
142
145
|
#
|
146
|
+
# If using the +save+ action for the machine, this option will be ignored as
|
147
|
+
# the transaction behavior will depend on the +save+ implementation within
|
148
|
+
# DataMapper.
|
149
|
+
#
|
143
150
|
# == Validation errors
|
144
151
|
#
|
145
152
|
# If an event fails to successfully fire because there are no matching
|
@@ -256,9 +263,12 @@ module StateMachine
|
|
256
263
|
def write(object, attribute, value)
|
257
264
|
result = super
|
258
265
|
if attribute == :state && owner_class.properties.detect {|property| property.name == self.attribute}
|
259
|
-
if ::DataMapper::VERSION =~ /^
|
266
|
+
if ::DataMapper::VERSION =~ /^0\.\d\./ # Match anything < 0.10
|
260
267
|
object.original_values[self.attribute] = "#{value}-ignored" if object.original_values[self.attribute] == value
|
261
268
|
else
|
269
|
+
# Force the resource's state to Dirty for 0.10.13+
|
270
|
+
object.persisted_state = ::DataMapper::Resource::State::Dirty.new(object) unless ::DataMapper::VERSION =~ /^0\.10\.[0-2]$/ || object.persisted_state.respond_to?(:original_attributes)
|
271
|
+
|
262
272
|
property = owner_class.properties[self.attribute]
|
263
273
|
object.original_attributes[property] = "#{value}-ignored" unless object.original_attributes.include?(property)
|
264
274
|
end
|
@@ -284,7 +294,7 @@ module StateMachine
|
|
284
294
|
|
285
295
|
# Pluralizes the name using the built-in inflector
|
286
296
|
def pluralize(word)
|
287
|
-
Extlib::Inflection.pluralize(word.to_s)
|
297
|
+
defined?(Extlib::Inflection) ? Extlib::Inflection.pluralize(word.to_s) : super
|
288
298
|
end
|
289
299
|
|
290
300
|
# Defines an initialization hook into the owner class for setting the
|
@@ -316,12 +326,20 @@ module StateMachine
|
|
316
326
|
|
317
327
|
# Adds hooks into validation for automatically firing events
|
318
328
|
def define_action_helpers
|
319
|
-
|
320
|
-
|
321
|
-
|
322
|
-
|
329
|
+
# 0.9.4 - 0.9.6 fails to run after callbacks when validations are
|
330
|
+
# enabled because of the way dm-validations integrates
|
331
|
+
return if ::DataMapper::VERSION =~ /^0\.9\.[4-6]/ && supports_validations?
|
332
|
+
|
333
|
+
if action == :save
|
334
|
+
if super(::DataMapper::VERSION =~ /^0\.\d\./ ? :save : :save_self) && supports_validations?
|
335
|
+
@instance_helper_module.class_eval do
|
336
|
+
define_method(:valid?) do |*args|
|
337
|
+
self.class.state_machines.transitions(self, :save, :after => false).perform { super(*args) }
|
338
|
+
end
|
323
339
|
end
|
324
340
|
end
|
341
|
+
else
|
342
|
+
super
|
325
343
|
end
|
326
344
|
end
|
327
345
|
|
@@ -104,6 +104,42 @@ module StateMachine
|
|
104
104
|
add_transition_callback(:after, *args, &block)
|
105
105
|
end
|
106
106
|
|
107
|
+
# Creates a callback that will be invoked *around* a transition so long
|
108
|
+
# as the given requirements match the transition.
|
109
|
+
#
|
110
|
+
# == Examples
|
111
|
+
#
|
112
|
+
# class Vehicle
|
113
|
+
# include DataMapper::Resource
|
114
|
+
#
|
115
|
+
# property :id, Serial
|
116
|
+
# property :state, :String
|
117
|
+
#
|
118
|
+
# state_machine :initial => :parked do
|
119
|
+
# event :ignite do
|
120
|
+
# transition :parked => :idling
|
121
|
+
# end
|
122
|
+
# end
|
123
|
+
# end
|
124
|
+
#
|
125
|
+
# class VehicleObserver
|
126
|
+
# include DataMapper::Observer
|
127
|
+
#
|
128
|
+
# observe Vehicle
|
129
|
+
#
|
130
|
+
# around_transition do |transition, block|
|
131
|
+
# # track start time
|
132
|
+
# block.call
|
133
|
+
# # track end time
|
134
|
+
# end
|
135
|
+
# end
|
136
|
+
#
|
137
|
+
# See +before_transition+ for a description of the possible configurations
|
138
|
+
# for defining callbacks.
|
139
|
+
def around_transition(*args, &block)
|
140
|
+
add_transition_callback(:around, *args, &block)
|
141
|
+
end
|
142
|
+
|
107
143
|
private
|
108
144
|
# Adds the transition callback to a specific machine or all of the
|
109
145
|
# state machines for each observed class.
|
@@ -0,0 +1,295 @@
|
|
1
|
+
module StateMachine
|
2
|
+
module Integrations #:nodoc:
|
3
|
+
# Adds support for integrating state machines with MongoMapper models.
|
4
|
+
#
|
5
|
+
# == Examples
|
6
|
+
#
|
7
|
+
# Below is an example of a simple state machine defined within a
|
8
|
+
# MongoMapper model:
|
9
|
+
#
|
10
|
+
# class Vehicle
|
11
|
+
# include MongoMapper::Document
|
12
|
+
#
|
13
|
+
# state_machine :initial => :parked do
|
14
|
+
# event :ignite do
|
15
|
+
# transition :parked => :idling
|
16
|
+
# end
|
17
|
+
# end
|
18
|
+
# end
|
19
|
+
#
|
20
|
+
# The examples in the sections below will use the above class as a
|
21
|
+
# reference.
|
22
|
+
#
|
23
|
+
# == Actions
|
24
|
+
#
|
25
|
+
# By default, the action that will be invoked when a state is transitioned
|
26
|
+
# is the +save+ action. This will cause the record to save the changes
|
27
|
+
# made to the state machine's attribute. *Note* that if any other changes
|
28
|
+
# were made to the record prior to transition, then those changes will
|
29
|
+
# be saved as well.
|
30
|
+
#
|
31
|
+
# For example,
|
32
|
+
#
|
33
|
+
# vehicle = Vehicle.create # => #<Vehicle id: 1, name: nil, state: "parked">
|
34
|
+
# vehicle.name = 'Ford Explorer'
|
35
|
+
# vehicle.ignite # => true
|
36
|
+
# vehicle.reload # => #<Vehicle id: 1, name: "Ford Explorer", state: "idling">
|
37
|
+
#
|
38
|
+
# == Events
|
39
|
+
#
|
40
|
+
# As described in StateMachine::InstanceMethods#state_machine, event
|
41
|
+
# attributes are created for every machine that allow transitions to be
|
42
|
+
# performed automatically when the object's action (in this case, :save)
|
43
|
+
# is called.
|
44
|
+
#
|
45
|
+
# In MongoMapper, these automated events are run in the following order:
|
46
|
+
# * before validation - Run before callbacks and persist new states, then validate
|
47
|
+
# * before save - If validation was skipped, run before callbacks and persist new states, then save
|
48
|
+
# * after save - Run after callbacks
|
49
|
+
#
|
50
|
+
# For example,
|
51
|
+
#
|
52
|
+
# vehicle = Vehicle.create # => #<Vehicle id: 1, name: nil, state: "parked">
|
53
|
+
# vehicle.state_event # => nil
|
54
|
+
# vehicle.state_event = 'invalid'
|
55
|
+
# vehicle.valid? # => false
|
56
|
+
# vehicle.errors.full_messages # => ["State event is invalid"]
|
57
|
+
#
|
58
|
+
# vehicle.state_event = 'ignite'
|
59
|
+
# vehicle.valid? # => true
|
60
|
+
# vehicle.save # => true
|
61
|
+
# vehicle.state # => "idling"
|
62
|
+
# vehicle.state_event # => nil
|
63
|
+
#
|
64
|
+
# Note that this can also be done on a mass-assignment basis:
|
65
|
+
#
|
66
|
+
# vehicle = Vehicle.create(:state_event => 'ignite') # => #<Vehicle id: 1, name: nil, state: "idling">
|
67
|
+
# vehicle.state # => "idling"
|
68
|
+
#
|
69
|
+
# This technique is always used for transitioning states when the +save+
|
70
|
+
# action (which is the default) is configured for the machine.
|
71
|
+
#
|
72
|
+
# === Security implications
|
73
|
+
#
|
74
|
+
# Beware that public event attributes mean that events can be fired
|
75
|
+
# whenever mass-assignment is being used. If you want to prevent malicious
|
76
|
+
# users from tampering with events through URLs / forms, the attribute
|
77
|
+
# should be protected like so:
|
78
|
+
#
|
79
|
+
# class Vehicle
|
80
|
+
# include MongoMapper::Document
|
81
|
+
#
|
82
|
+
# attr_protected :state_event
|
83
|
+
# # attr_accessible ... # Alternative technique
|
84
|
+
#
|
85
|
+
# state_machine do
|
86
|
+
# ...
|
87
|
+
# end
|
88
|
+
# end
|
89
|
+
#
|
90
|
+
# If you want to only have *some* events be able to fire via mass-assignment,
|
91
|
+
# you can build two state machines (one public and one protected) like so:
|
92
|
+
#
|
93
|
+
# class Vehicle
|
94
|
+
# include MongoMapper::Document
|
95
|
+
#
|
96
|
+
# attr_protected :state_event # Prevent access to events in the first machine
|
97
|
+
#
|
98
|
+
# state_machine do
|
99
|
+
# # Define private events here
|
100
|
+
# end
|
101
|
+
#
|
102
|
+
# # Public machine targets the same state as the private machine
|
103
|
+
# state_machine :public_state, :attribute => :state do
|
104
|
+
# # Define public events here
|
105
|
+
# end
|
106
|
+
# end
|
107
|
+
#
|
108
|
+
# == Validation errors
|
109
|
+
#
|
110
|
+
# If an event fails to successfully fire because there are no matching
|
111
|
+
# transitions for the current record, a validation error is added to the
|
112
|
+
# record's state attribute to help in determining why it failed and for
|
113
|
+
# reporting via the UI.
|
114
|
+
#
|
115
|
+
# For example,
|
116
|
+
#
|
117
|
+
# vehicle = Vehicle.create(:state => 'idling') # => #<Vehicle id: 1, name: nil, state: "idling">
|
118
|
+
# vehicle.ignite # => false
|
119
|
+
# vehicle.errors.full_messages # => ["State cannot transition via \"ignite\""]
|
120
|
+
#
|
121
|
+
# If an event fails to fire because of a validation error on the record and
|
122
|
+
# *not* because a matching transition was not available, no error messages
|
123
|
+
# will be added to the state attribute.
|
124
|
+
#
|
125
|
+
# == Scopes
|
126
|
+
#
|
127
|
+
# To assist in filtering models with specific states, a series of basic
|
128
|
+
# scopes are defined on the model for finding records with or without a
|
129
|
+
# particular set of states.
|
130
|
+
#
|
131
|
+
# These scopes are essentially the functional equivalent of the following
|
132
|
+
# definitions:
|
133
|
+
#
|
134
|
+
# class Vehicle
|
135
|
+
# include MongoMapper::Document
|
136
|
+
#
|
137
|
+
# def self.with_states(*states)
|
138
|
+
# all(:conditions => {:state => {'$in' => states}})
|
139
|
+
# end
|
140
|
+
# # with_states also aliased to with_state
|
141
|
+
#
|
142
|
+
# def self.without_states(*states)
|
143
|
+
# all(:conditions => {:state => {'$nin' => states}})
|
144
|
+
# end
|
145
|
+
# # without_states also aliased to without_state
|
146
|
+
# end
|
147
|
+
#
|
148
|
+
# *Note*, however, that the states are converted to their stored values
|
149
|
+
# before being passed into the query.
|
150
|
+
#
|
151
|
+
# Because of the way named scopes work in MongoMapper, they *cannot* be
|
152
|
+
# chained.
|
153
|
+
#
|
154
|
+
# == Callbacks
|
155
|
+
#
|
156
|
+
# All before/after transition callbacks defined for MongoMapper models
|
157
|
+
# behave in the same way that other MongoMapper callbacks behave. The
|
158
|
+
# object involved in the transition is passed in as an argument.
|
159
|
+
#
|
160
|
+
# For example,
|
161
|
+
#
|
162
|
+
# class Vehicle
|
163
|
+
# include MongoMapper::Document
|
164
|
+
#
|
165
|
+
# state_machine :initial => :parked do
|
166
|
+
# before_transition any => :idling do |vehicle|
|
167
|
+
# vehicle.put_on_seatbelt
|
168
|
+
# end
|
169
|
+
#
|
170
|
+
# before_transition do |vehicle, transition|
|
171
|
+
# # log message
|
172
|
+
# end
|
173
|
+
#
|
174
|
+
# event :ignite do
|
175
|
+
# transition :parked => :idling
|
176
|
+
# end
|
177
|
+
# end
|
178
|
+
#
|
179
|
+
# def put_on_seatbelt
|
180
|
+
# ...
|
181
|
+
# end
|
182
|
+
# end
|
183
|
+
#
|
184
|
+
# Note, also, that the transition can be accessed by simply defining
|
185
|
+
# additional arguments in the callback block.
|
186
|
+
module MongoMapper
|
187
|
+
include ActiveModel
|
188
|
+
|
189
|
+
# The default options to use for state machines using this integration
|
190
|
+
@defaults = {:action => :save}
|
191
|
+
|
192
|
+
# Should this integration be used for state machines in the given class?
|
193
|
+
# Classes that include MongoMapper::Document will automatically use the
|
194
|
+
# MongoMapper integration.
|
195
|
+
def self.matches?(klass)
|
196
|
+
defined?(::MongoMapper::Document) && klass <= ::MongoMapper::Document
|
197
|
+
end
|
198
|
+
|
199
|
+
# Adds a validation error to the given object (no i18n support)
|
200
|
+
def invalidate(object, attribute, message, values = [])
|
201
|
+
object.errors.add(self.attribute(attribute), generate_message(message, values))
|
202
|
+
end
|
203
|
+
|
204
|
+
protected
|
205
|
+
# Does not support observers
|
206
|
+
def supports_observers?
|
207
|
+
false
|
208
|
+
end
|
209
|
+
|
210
|
+
# Always adds validation support
|
211
|
+
def supports_validations?
|
212
|
+
true
|
213
|
+
end
|
214
|
+
|
215
|
+
# Only runs validations on the action if using <tt>:save</tt>
|
216
|
+
def runs_validations_on_action?
|
217
|
+
action == :save
|
218
|
+
end
|
219
|
+
|
220
|
+
# Always adds dirty tracking support
|
221
|
+
def supports_dirty_tracking?(object)
|
222
|
+
true
|
223
|
+
end
|
224
|
+
|
225
|
+
# Don't allow callback terminators
|
226
|
+
def callback_terminator
|
227
|
+
end
|
228
|
+
|
229
|
+
# Defines an initialization hook into the owner class for setting the
|
230
|
+
# initial state of the machine *before* any attributes are set on the
|
231
|
+
# object
|
232
|
+
def define_state_initializer
|
233
|
+
@instance_helper_module.class_eval <<-end_eval, __FILE__, __LINE__
|
234
|
+
def initialize(attrs = {}, *args)
|
235
|
+
from_database = args.first
|
236
|
+
|
237
|
+
if !from_database && (!attrs || !attrs.stringify_keys.key?('_id'))
|
238
|
+
filtered = respond_to?(:filter_protected_attrs) ? filter_protected_attrs(attrs) : attrs
|
239
|
+
ignore = filtered ? filtered.keys : []
|
240
|
+
|
241
|
+
initialize_state_machines(:dynamic => false, :ignore => ignore)
|
242
|
+
super
|
243
|
+
initialize_state_machines(:dynamic => true, :ignore => ignore)
|
244
|
+
else
|
245
|
+
super
|
246
|
+
end
|
247
|
+
end
|
248
|
+
end_eval
|
249
|
+
end
|
250
|
+
|
251
|
+
# Skips defining reader/writer methods since this is done automatically
|
252
|
+
def define_state_accessor
|
253
|
+
owner_class.key(attribute, String) unless owner_class.keys.include?(attribute)
|
254
|
+
|
255
|
+
name = self.name
|
256
|
+
owner_class.validates_each(attribute, :logic => lambda {
|
257
|
+
machine = self.class.state_machine(name)
|
258
|
+
machine.invalidate(self, :state, :invalid) unless machine.states.match(self)
|
259
|
+
})
|
260
|
+
end
|
261
|
+
|
262
|
+
# Adds support for defining the attribute predicate, while providing
|
263
|
+
# compatibility with the default predicate which determines whether
|
264
|
+
# *anything* is set for the attribute's value
|
265
|
+
def define_state_predicate
|
266
|
+
name = self.name
|
267
|
+
|
268
|
+
# Still use class_eval here instance of define_instance_method since
|
269
|
+
# we need to be able to call +super+
|
270
|
+
@instance_helper_module.class_eval do
|
271
|
+
define_method("#{name}?") do |*args|
|
272
|
+
args.empty? ? super(*args) : self.class.state_machine(name).states.matches?(self, *args)
|
273
|
+
end
|
274
|
+
end
|
275
|
+
end
|
276
|
+
|
277
|
+
# Adds hooks into validation for automatically firing events
|
278
|
+
def define_action_helpers
|
279
|
+
super(action == :save ? :create_or_update : action)
|
280
|
+
end
|
281
|
+
|
282
|
+
# Creates a scope for finding records *with* a particular state or
|
283
|
+
# states for the attribute
|
284
|
+
def create_with_scope(name)
|
285
|
+
lambda {|model, values| model.all(:conditions => {attribute => {'$in' => values}})}
|
286
|
+
end
|
287
|
+
|
288
|
+
# Creates a scope for finding records *without* a particular state or
|
289
|
+
# states for the attribute
|
290
|
+
def create_without_scope(name)
|
291
|
+
lambda {|model, values| model.all(:conditions => {attribute => {'$nin' => values}})}
|
292
|
+
end
|
293
|
+
end
|
294
|
+
end
|
295
|
+
end
|