state_machine 0.9.4 → 0.10.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 (68) hide show
  1. data/CHANGELOG.rdoc +20 -0
  2. data/LICENSE +1 -1
  3. data/README.rdoc +74 -4
  4. data/Rakefile +3 -3
  5. data/lib/state_machine.rb +51 -24
  6. data/lib/state_machine/{guard.rb → branch.rb} +34 -40
  7. data/lib/state_machine/callback.rb +13 -18
  8. data/lib/state_machine/error.rb +13 -0
  9. data/lib/state_machine/eval_helpers.rb +3 -0
  10. data/lib/state_machine/event.rb +67 -30
  11. data/lib/state_machine/event_collection.rb +20 -3
  12. data/lib/state_machine/extensions.rb +3 -3
  13. data/lib/state_machine/integrations.rb +7 -0
  14. data/lib/state_machine/integrations/active_model.rb +149 -59
  15. data/lib/state_machine/integrations/active_model/versions.rb +30 -0
  16. data/lib/state_machine/integrations/active_record.rb +74 -148
  17. data/lib/state_machine/integrations/active_record/locale.rb +0 -7
  18. data/lib/state_machine/integrations/active_record/versions.rb +149 -0
  19. data/lib/state_machine/integrations/base.rb +64 -0
  20. data/lib/state_machine/integrations/data_mapper.rb +50 -39
  21. data/lib/state_machine/integrations/data_mapper/observer.rb +47 -12
  22. data/lib/state_machine/integrations/data_mapper/versions.rb +62 -0
  23. data/lib/state_machine/integrations/mongo_mapper.rb +37 -64
  24. data/lib/state_machine/integrations/mongo_mapper/locale.rb +4 -0
  25. data/lib/state_machine/integrations/mongo_mapper/versions.rb +102 -0
  26. data/lib/state_machine/integrations/mongoid.rb +297 -0
  27. data/lib/state_machine/integrations/mongoid/locale.rb +4 -0
  28. data/lib/state_machine/integrations/mongoid/versions.rb +18 -0
  29. data/lib/state_machine/integrations/sequel.rb +99 -55
  30. data/lib/state_machine/integrations/sequel/versions.rb +40 -0
  31. data/lib/state_machine/machine.rb +273 -136
  32. data/lib/state_machine/machine_collection.rb +21 -13
  33. data/lib/state_machine/node_collection.rb +6 -1
  34. data/lib/state_machine/path.rb +120 -0
  35. data/lib/state_machine/path_collection.rb +90 -0
  36. data/lib/state_machine/state.rb +28 -9
  37. data/lib/state_machine/state_collection.rb +1 -1
  38. data/lib/state_machine/transition.rb +65 -6
  39. data/lib/state_machine/transition_collection.rb +1 -1
  40. data/test/files/en.yml +8 -0
  41. data/test/functional/state_machine_test.rb +15 -2
  42. data/test/unit/branch_test.rb +890 -0
  43. data/test/unit/callback_test.rb +9 -36
  44. data/test/unit/error_test.rb +43 -0
  45. data/test/unit/event_collection_test.rb +67 -33
  46. data/test/unit/event_test.rb +165 -38
  47. data/test/unit/integrations/active_model_test.rb +103 -3
  48. data/test/unit/integrations/active_record_test.rb +90 -43
  49. data/test/unit/integrations/base_test.rb +87 -0
  50. data/test/unit/integrations/data_mapper_test.rb +105 -44
  51. data/test/unit/integrations/mongo_mapper_test.rb +261 -64
  52. data/test/unit/integrations/mongoid_test.rb +1529 -0
  53. data/test/unit/integrations/sequel_test.rb +33 -49
  54. data/test/unit/integrations_test.rb +4 -0
  55. data/test/unit/invalid_event_test.rb +15 -2
  56. data/test/unit/invalid_parallel_transition_test.rb +18 -0
  57. data/test/unit/invalid_transition_test.rb +72 -2
  58. data/test/unit/machine_collection_test.rb +55 -61
  59. data/test/unit/machine_test.rb +388 -26
  60. data/test/unit/node_collection_test.rb +14 -4
  61. data/test/unit/path_collection_test.rb +266 -0
  62. data/test/unit/path_test.rb +485 -0
  63. data/test/unit/state_collection_test.rb +30 -0
  64. data/test/unit/state_test.rb +82 -35
  65. data/test/unit/transition_collection_test.rb +48 -44
  66. data/test/unit/transition_test.rb +198 -41
  67. metadata +111 -74
  68. data/test/unit/guard_test.rb +0 -909
@@ -1,4 +1,4 @@
1
- require 'state_machine/guard'
1
+ require 'state_machine/branch'
2
2
  require 'state_machine/eval_helpers'
3
3
 
4
4
  module StateMachine
@@ -67,6 +67,7 @@ module StateMachine
67
67
  # * +before+
68
68
  # * +after+
69
69
  # * +around+
70
+ # * +failure+
70
71
  attr_accessor :type
71
72
 
72
73
  # An optional block for determining whether to cancel the callback chain
@@ -98,17 +99,17 @@ module StateMachine
98
99
  # end
99
100
  attr_reader :terminator
100
101
 
101
- # The guard that determines whether or not this callback can be invoked
102
+ # The branch that determines whether or not this callback can be invoked
102
103
  # based on the context of the transition. The event, from state, and
103
- # to state must all match in order for the guard to pass.
104
+ # to state must all match in order for the branch to pass.
104
105
  #
105
- # See StateMachine::Guard for more information.
106
- attr_reader :guard
106
+ # See StateMachine::Branch for more information.
107
+ attr_reader :branch
107
108
 
108
109
  # Creates a new callback that can get called based on the configured
109
110
  # options.
110
111
  #
111
- # In addition to the possible configuration options for guards, the
112
+ # In addition to the possible configuration options for branches, the
112
113
  # following options can be configured:
113
114
  # * <tt>:bind_to_object</tt> - Whether to bind the callback to the object involved.
114
115
  # If set to false, the object will be passed as a parameter instead.
@@ -121,7 +122,7 @@ module StateMachine
121
122
  # callback can be found in their attribute definitions.
122
123
  def initialize(type, *args, &block)
123
124
  @type = type
124
- raise ArgumentError, 'Type must be :before, :after, or :around' unless [:before, :after, :around].include?(type)
125
+ raise ArgumentError, 'Type must be :before, :after, :around, or :failure' unless [:before, :after, :around, :failure].include?(type)
125
126
 
126
127
  options = args.last.is_a?(Hash) ? args.pop : {}
127
128
  @methods = args
@@ -138,23 +139,23 @@ module StateMachine
138
139
  end
139
140
 
140
141
  @terminator = options.delete(:terminator)
141
- @guard = Guard.new(options)
142
+ @branch = Branch.new(options)
142
143
  end
143
144
 
144
145
  # Gets a list of the states known to this callback by looking at the
145
- # guard's known states
146
+ # branch's known states
146
147
  def known_states
147
- guard.known_states
148
+ branch.known_states
148
149
  end
149
150
 
150
- # Runs the callback as long as the transition context matches the guard
151
+ # Runs the callback as long as the transition context matches the branch
151
152
  # requirements configured for this callback. If a block is provided, it
152
153
  # will be called when the last method has run.
153
154
  #
154
155
  # If a terminator has been configured and it matches the result from the
155
156
  # evaluated method, then the callback chain should be halted.
156
157
  def call(object, context = {}, *args, &block)
157
- if @guard.matches?(object, context)
158
+ if @branch.matches?(object, context)
158
159
  run_methods(object, context, 0, *args, &block)
159
160
  true
160
161
  else
@@ -162,12 +163,6 @@ module StateMachine
162
163
  end
163
164
  end
164
165
 
165
- # Verifies that the success requirement for this callback matches the given
166
- # value
167
- def matches_success?(success)
168
- guard.success_requirement.matches?(success)
169
- end
170
-
171
166
  private
172
167
  # Runs all of the methods configured for this callback.
173
168
  #
@@ -0,0 +1,13 @@
1
+ module StateMachine
2
+ # An error occurred during a state machine invocation
3
+ class Error < StandardError
4
+ # The object that failed
5
+ attr_reader :object
6
+
7
+ def initialize(object, message = nil) #:nodoc:
8
+ @object = object
9
+
10
+ super(message)
11
+ end
12
+ end
13
+ end
@@ -64,9 +64,12 @@ module StateMachine
64
64
  # supports yielding within blocks)
65
65
  if block_given? && Proc === method && arity != 0
66
66
  if [1, 2].include?(arity)
67
+ # Force the block to be either the only argument or the 2nd one
68
+ # after the object (may mean additional arguments get discarded)
67
69
  limit = arity
68
70
  args.insert(limit - 1, block)
69
71
  else
72
+ # Tack the block to the end of the args
70
73
  limit += 1 unless limit < 0
71
74
  args.push(block)
72
75
  end
@@ -1,16 +1,25 @@
1
1
  require 'state_machine/transition'
2
- require 'state_machine/guard'
2
+ require 'state_machine/branch'
3
3
  require 'state_machine/assertions'
4
4
  require 'state_machine/matcher_helpers'
5
+ require 'state_machine/error'
5
6
 
6
7
  module StateMachine
7
8
  # An invalid event was specified
8
- class InvalidEvent < StandardError
9
+ class InvalidEvent < Error
10
+ # The event that was attempted to be run
11
+ attr_reader :event
12
+
13
+ def initialize(object, event_name) #:nodoc:
14
+ @event = event_name
15
+
16
+ super(object, "#{event.inspect} is an unknown state machine event")
17
+ end
9
18
  end
10
19
 
11
20
  # An event defines an action that transitions an attribute from one state to
12
21
  # another. The state that an attribute is transitioned to depends on the
13
- # guards configured for the event.
22
+ # branches configured for the event.
14
23
  class Event
15
24
  include Assertions
16
25
  include MatcherHelpers
@@ -27,12 +36,12 @@ module StateMachine
27
36
  # The human-readable name for the event
28
37
  attr_writer :human_name
29
38
 
30
- # The list of guards that determine what state this event transitions
39
+ # The list of branches that determine what state this event transitions
31
40
  # objects to when fired
32
- attr_reader :guards
41
+ attr_reader :branches
33
42
 
34
43
  # A list of all of the states known to this event using the configured
35
- # guards/transitions as the source
44
+ # branches/transitions as the source
36
45
  attr_reader :known_states
37
46
 
38
47
  # Creates a new event within the context of the given machine
@@ -46,17 +55,23 @@ module StateMachine
46
55
  @name = name
47
56
  @qualified_name = machine.namespace ? :"#{name}_#{machine.namespace}" : name
48
57
  @human_name = options[:human_name] || @name.to_s.tr('_', ' ')
49
- @guards = []
58
+ @branches = []
50
59
  @known_states = []
51
60
 
52
- add_actions
61
+ # Output a warning if another event has a conflicting qualified name
62
+ if conflict = machine.owner_class.state_machines.detect {|name, other_machine| other_machine != @machine && other_machine.events[qualified_name, :qualified_name]}
63
+ name, other_machine = conflict
64
+ warn "Event #{qualified_name.inspect} for #{machine.name.inspect} is already defined in #{other_machine.name.inspect}"
65
+ else
66
+ add_actions
67
+ end
53
68
  end
54
69
 
55
70
  # Creates a copy of this event in addition to the list of associated
56
- # guards to prevent conflicts across events within a class hierarchy.
71
+ # branches to prevent conflicts across events within a class hierarchy.
57
72
  def initialize_copy(orig) #:nodoc:
58
73
  super
59
- @guards = @guards.dup
74
+ @branches = @branches.dup
60
75
  @known_states = @known_states.dup
61
76
  end
62
77
 
@@ -101,6 +116,9 @@ module StateMachine
101
116
  # transition :parked => same # Loops :parked back to :parked
102
117
  # transition [:parked, :stalled] => same # Loops either :parked or :stalled back to the same state
103
118
  # transition all - :parked => same # Loops every state but :parked back to the same state
119
+ #
120
+ # # Transitions to :idling if :parked, :first_gear if :idling, or :second_gear if :first_gear
121
+ # transition :parked => :idling, :idling => :first_gear, :first_gear => :second_gear
104
122
  #
105
123
  # == Verbose transitions
106
124
  #
@@ -146,6 +164,7 @@ module StateMachine
146
164
  #
147
165
  # transition :parked => :idling, :if => :moving?
148
166
  # transition :parked => :idling, :unless => :stopped?
167
+ # transition :idling => :first_gear, :first_gear => :second_gear, :if => :seatbelt_on?
149
168
  #
150
169
  # transition :from => :parked, :to => :idling, :if => :moving?
151
170
  # transition :from => :parked, :to => :idling, :unless => :stopped?
@@ -162,27 +181,36 @@ module StateMachine
162
181
  # requirements
163
182
  assert_valid_keys(options, :from, :to, :except_from, :if, :unless) if (options.keys - [:from, :to, :on, :except_from, :except_to, :except_on, :if, :unless]).empty?
164
183
 
165
- guards << guard = Guard.new(options.merge(:on => name))
166
- @known_states |= guard.known_states
167
- guard
184
+ branches << branch = Branch.new(options.merge(:on => name))
185
+ @known_states |= branch.known_states
186
+ branch
168
187
  end
169
188
 
170
189
  # Determines whether any transitions can be performed for this event based
171
190
  # on the current state of the given object.
172
191
  #
173
192
  # If the event can't be fired, then this will return false, otherwise true.
174
- def can_fire?(object)
175
- !transition_for(object).nil?
193
+ def can_fire?(object, requirements = {})
194
+ !transition_for(object, requirements).nil?
176
195
  end
177
196
 
178
197
  # Finds and builds the next transition that can be performed on the given
179
198
  # object. If no transitions can be made, then this will return nil.
199
+ #
200
+ # Valid requirement options:
201
+ # * <tt>:from</tt> - One or more states being transitioned from. If none
202
+ # are specified, then this will be the object's current state.
203
+ # * <tt>:to</tt> - One or more states being transitioned to. If none are
204
+ # specified, then this will match any to state.
205
+ # * <tt>:guard</tt> - Whether to guard transitions with the if/unless
206
+ # conditionals defined for each one. Default is true.
180
207
  def transition_for(object, requirements = {})
208
+ assert_valid_keys(requirements, :from, :to, :guard)
181
209
  requirements[:from] = machine.states.match!(object).name unless custom_from_state = requirements.include?(:from)
182
210
 
183
- guards.each do |guard|
184
- if match = guard.match(object, requirements)
185
- # Guard allows for the transition to occur
211
+ branches.each do |branch|
212
+ if match = branch.match(object, requirements)
213
+ # Branch allows for the transition to occur
186
214
  from = requirements[:from]
187
215
  to = match[:to].values.empty? ? from : match[:to].values.first
188
216
 
@@ -206,19 +234,28 @@ module StateMachine
206
234
  if transition = transition_for(object)
207
235
  transition.perform(*args)
208
236
  else
209
- machine.invalidate(object, :state, :invalid_transition, [[:event, human_name(object.class)]])
237
+ on_failure(object)
210
238
  false
211
239
  end
212
240
  end
213
241
 
242
+ # Marks the object as invalid and runs any failure callbacks associated with
243
+ # this event. This should get called anytime this event fails to transition.
244
+ def on_failure(object)
245
+ machine.invalidate(object, :state, :invalid_transition, [[:event, human_name(object.class)]])
246
+
247
+ state = machine.states.match!(object).name
248
+ Transition.new(object, machine, name, state, state).run_callbacks(:before => false)
249
+ end
250
+
214
251
  # Draws a representation of this event on the given graph. This will
215
- # create 1 or more edges on the graph for each guard (i.e. transition)
252
+ # create 1 or more edges on the graph for each branch (i.e. transition)
216
253
  # configured.
217
254
  #
218
255
  # A collection of the generated edges will be returned.
219
256
  def draw(graph)
220
257
  valid_states = machine.states.by_priority.map {|state| state.name}
221
- guards.collect {|guard| guard.draw(graph, name, valid_states)}.flatten
258
+ branches.collect {|branch| branch.draw(graph, name, valid_states)}.flatten
222
259
  end
223
260
 
224
261
  # Generates a nicely formatted description of this event's contents.
@@ -229,8 +266,8 @@ module StateMachine
229
266
  # event.transition all - :idling => :parked, :idling => same
230
267
  # event # => #<StateMachine::Event name=:park transitions=[all - :idling => :parked, :idling => same]>
231
268
  def inspect
232
- transitions = guards.map do |guard|
233
- guard.state_requirements.map do |state_requirement|
269
+ transitions = branches.map do |branch|
270
+ branch.state_requirements.map do |state_requirement|
234
271
  "#{state_requirement[:from].description} => #{state_requirement[:to].description}"
235
272
  end * ', '
236
273
  end
@@ -243,24 +280,24 @@ module StateMachine
243
280
  # the current event
244
281
  def add_actions
245
282
  # Checks whether the event can be fired on the current object
246
- machine.define_instance_method("can_#{qualified_name}?") do |machine, object|
247
- machine.event(name).can_fire?(object)
283
+ machine.define_helper(:instance, "can_#{qualified_name}?") do |machine, object, _super, *args|
284
+ machine.event(name).can_fire?(object, *args)
248
285
  end
249
286
 
250
287
  # Gets the next transition that would be performed if the event were
251
288
  # fired now
252
- machine.define_instance_method("#{qualified_name}_transition") do |machine, object|
253
- machine.event(name).transition_for(object)
289
+ machine.define_helper(:instance, "#{qualified_name}_transition") do |machine, object, _super, *args|
290
+ machine.event(name).transition_for(object, *args)
254
291
  end
255
292
 
256
293
  # Fires the event
257
- machine.define_instance_method(qualified_name) do |machine, object, *args|
294
+ machine.define_helper(:instance, qualified_name) do |machine, object, _super, *args|
258
295
  machine.event(name).fire(object, *args)
259
296
  end
260
297
 
261
298
  # Fires the event, raising an exception if it fails
262
- machine.define_instance_method("#{qualified_name}!") do |machine, object, *args|
263
- object.send(qualified_name, *args) || raise(StateMachine::InvalidTransition, "Cannot transition #{machine.name} via :#{name} from #{machine.states.match!(object).name.inspect}")
299
+ machine.define_helper(:instance, "#{qualified_name}!") do |machine, object, _super, *args|
300
+ object.send(qualified_name, *args) || raise(StateMachine::InvalidTransition.new(object, machine, name))
264
301
  end
265
302
  end
266
303
  end
@@ -7,6 +7,16 @@ module StateMachine
7
7
 
8
8
  # Gets the list of events that can be fired on the given object.
9
9
  #
10
+ # Valid requirement options:
11
+ # * <tt>:from</tt> - One or more states being transitioned from. If none
12
+ # are specified, then this will be the object's current state.
13
+ # * <tt>:to</tt> - One or more states being transitioned to. If none are
14
+ # specified, then this will match any to state.
15
+ # * <tt>:on</tt> - One or more events that fire the transition. If none
16
+ # are specified, then this will match any event.
17
+ # * <tt>:guard</tt> - Whether to guard transitions with the if/unless
18
+ # conditionals defined for each one. Default is true.
19
+ #
10
20
  # == Examples
11
21
  #
12
22
  # class Vehicle
@@ -28,8 +38,8 @@ module StateMachine
28
38
  #
29
39
  # vehicle.state = 'idling'
30
40
  # events.valid_for(vehicle) # => [#<StateMachine::Event name=:park transitions=[:idling => :parked]>]
31
- def valid_for(object)
32
- select {|event| event.can_fire?(object)}
41
+ def valid_for(object, requirements = {})
42
+ match(requirements).select {|event| event.can_fire?(object, requirements)}
33
43
  end
34
44
 
35
45
  # Gets the list of transitions that can be run on the given object.
@@ -41,6 +51,8 @@ module StateMachine
41
51
  # specified, then this will match any to state.
42
52
  # * <tt>:on</tt> - One or more events that fire the transition. If none
43
53
  # are specified, then this will match any event.
54
+ # * <tt>:guard</tt> - Whether to guard transitions with the if/unless
55
+ # conditionals defined for each one. Default is true.
44
56
  #
45
57
  # == Examples
46
58
  #
@@ -67,7 +79,7 @@ module StateMachine
67
79
  # # Search for explicit transitions regardless of the current state
68
80
  # events.transitions_for(vehicle, :from => :parked) # => [#<StateMachine::Transition attribute=:state event=:ignite from="parked" from_name=:parked to="idling" to_name=:idling>]
69
81
  def transitions_for(object, requirements = {})
70
- map {|event| event.transition_for(object, requirements)}.compact
82
+ match(requirements).map {|event| event.transition_for(object, requirements)}.compact
71
83
  end
72
84
 
73
85
  # Gets the transition that should be performed for the event stored in the
@@ -118,5 +130,10 @@ module StateMachine
118
130
 
119
131
  result
120
132
  end
133
+
134
+ private
135
+ def match(requirements) #:nodoc:
136
+ requirements && requirements[:on] ? [fetch(requirements.delete(:on))] : self
137
+ end
121
138
  end
122
139
  end
@@ -138,12 +138,12 @@ module StateMachine
138
138
  # vehicle.fire_events!(:ignite, :disable_alarm) # => StateMachine::InvalidTranstion: Cannot run events in parallel: ignite, disable_alarm
139
139
  def fire_events!(*events)
140
140
  run_action = [true, false].include?(events.last) ? events.pop : true
141
- fire_events(*(events + [run_action])) || raise(StateMachine::InvalidTransition, "Cannot run events in parallel: #{events * ', '}")
141
+ fire_events(*(events + [run_action])) || raise(StateMachine::InvalidParallelTransition.new(self, events))
142
142
  end
143
143
 
144
144
  protected
145
- def initialize_state_machines(options = {}) #:nodoc:
146
- self.class.state_machines.initialize_states(self, options)
145
+ def initialize_state_machines(options = {}, &block) #:nodoc:
146
+ self.class.state_machines.initialize_states(self, options, &block)
147
147
  end
148
148
  end
149
149
  end
@@ -1,4 +1,5 @@
1
1
  # Load each available integration
2
+ require 'state_machine/integrations/base'
2
3
  Dir["#{File.dirname(__FILE__)}/integrations/*.rb"].sort.each do |path|
3
4
  require "state_machine/integrations/#{File.basename(path)}"
4
5
  end
@@ -45,6 +46,10 @@ module StateMachine
45
46
  # include DataMapper::Resource
46
47
  # end
47
48
  #
49
+ # class MongoidVehicle
50
+ # include Mongoid::Document
51
+ # end
52
+ #
48
53
  # class MongoMapperVehicle
49
54
  # include MongoMapper::Document
50
55
  # end
@@ -56,6 +61,7 @@ module StateMachine
56
61
  # StateMachine::Integrations.match(ActiveModelVehicle) # => StateMachine::Integrations::ActiveModel
57
62
  # StateMachine::Integrations.match(ActiveRecordVehicle) # => StateMachine::Integrations::ActiveRecord
58
63
  # StateMachine::Integrations.match(DataMapperVehicle) # => StateMachine::Integrations::DataMapper
64
+ # StateMachine::Integrations.match(MongoidVehicle) # => StateMachine::Integrations::Mongoid
59
65
  # StateMachine::Integrations.match(MongoMapperVehicle) # => StateMachine::Integrations::MongoMapper
60
66
  # StateMachine::Integrations.match(SequelVehicle) # => StateMachine::Integrations::Sequel
61
67
  def self.match(klass)
@@ -73,6 +79,7 @@ module StateMachine
73
79
  # StateMachine::Integrations.find(:active_record) # => StateMachine::Integrations::ActiveRecord
74
80
  # StateMachine::Integrations.find(:active_model) # => StateMachine::Integrations::ActiveModel
75
81
  # StateMachine::Integrations.find(:data_mapper) # => StateMachine::Integrations::DataMapper
82
+ # StateMachine::Integrations.find(:mongoid) # => StateMachine::Integrations::Mongoid
76
83
  # StateMachine::Integrations.find(:mongo_mapper) # => StateMachine::Integrations::MongoMapper
77
84
  # StateMachine::Integrations.find(:sequel) # => StateMachine::Integrations::Sequel
78
85
  # StateMachine::Integrations.find(:invalid) # => NameError: wrong constant name Invalid
@@ -8,6 +8,7 @@ module StateMachine
8
8
  # following features need to be included in order for the integration to be
9
9
  # detected:
10
10
  # * ActiveModel::Dirty
11
+ # * ActiveModel::MassAssignmentSecurity
11
12
  # * ActiveModel::Observing
12
13
  # * ActiveModel::Validations
13
14
  #
@@ -16,6 +17,7 @@ module StateMachine
16
17
  #
17
18
  # class Vehicle
18
19
  # include ActiveModel::Dirty
20
+ # include ActiveModel::MassAssignmentSecurity
19
21
  # include ActiveModel::Observing
20
22
  # include ActiveModel::Validations
21
23
  #
@@ -65,6 +67,44 @@ module StateMachine
65
67
  # vehicle.ignite # => false
66
68
  # vehicle.errors.full_messages # => ["State cannot transition via \"ignite\""]
67
69
  #
70
+ # === Security implications
71
+ #
72
+ # Beware that public event attributes mean that events can be fired
73
+ # whenever mass-assignment is being used. If you want to prevent malicious
74
+ # users from tampering with events through URLs / forms, the attribute
75
+ # should be protected like so:
76
+ #
77
+ # class Vehicle
78
+ # include ActiveModel::MassAssignmentSecurity
79
+ # attr_accessor :state
80
+ #
81
+ # attr_protected :state_event
82
+ # # attr_accessible ... # Alternative technique
83
+ #
84
+ # state_machine do
85
+ # ...
86
+ # end
87
+ # end
88
+ #
89
+ # If you want to only have *some* events be able to fire via mass-assignment,
90
+ # you can build two state machines (one public and one protected) like so:
91
+ #
92
+ # class Vehicle
93
+ # include ActiveModel::MassAssignmentSecurity
94
+ # attr_accessor :state
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
+ #
68
108
  # == Callbacks
69
109
  #
70
110
  # All before/after transition callbacks defined for ActiveModel models
@@ -108,15 +148,15 @@ module StateMachine
108
148
  # hooks *are* supported. For example, if a transition for a object's
109
149
  # +state+ attribute changes the state from +parked+ to +idling+ via the
110
150
  # +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
151
+ # * before/after/after_failure_to-_ignite_from_parked_to_idling
152
+ # * before/after/after_failure_to-_ignite_from_parked
153
+ # * before/after/after_failure_to-_ignite_to_idling
154
+ # * before/after/after_failure_to-_ignite
155
+ # * before/after/after_failure_to-_transition_state_from_parked_to_idling
156
+ # * before/after/after_failure_to-_transition_state_from_parked
157
+ # * before/after/after_failure_to-_transition_state_to_idling
158
+ # * before/after/after_failure_to-_transition_state
159
+ # * before/after/after_failure_to-_transition
120
160
  #
121
161
  # The following class shows an example of some of these hooks:
122
162
  #
@@ -135,6 +175,10 @@ module StateMachine
135
175
  # def after_transition(vehicle, transition)
136
176
  # Audit.log(vehicle, transition)
137
177
  # end
178
+ #
179
+ # def after_failure_to_transition(vehicle, transition)
180
+ # Audit.error(vehicle, transition)
181
+ # end
138
182
  # end
139
183
  #
140
184
  # More flexible transition callbacks can be defined directly within the
@@ -200,7 +244,7 @@ module StateMachine
200
244
  # end
201
245
  #
202
246
  # protected
203
- # def runs_validation_on_action?
247
+ # def runs_validations_on_action?
204
248
  # action == :persist
205
249
  # end
206
250
  #
@@ -214,47 +258,35 @@ module StateMachine
214
258
  # must add these independent of the ActiveModel integration. See the
215
259
  # ActiveRecord implementation for examples of these customizations.
216
260
  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 defined?(I18n)
226
- locale = "#{File.dirname(__FILE__)}/active_model/locale.rb"
227
- I18n.load_path.unshift(locale) unless I18n.load_path.include?(locale)
228
- end
229
- end
230
- end
231
-
232
261
  def self.included(base) #:nodoc:
233
- base.class_eval do
234
- extend ClassMethods
235
- end
262
+ base.versions.unshift(*versions)
236
263
  end
237
264
 
265
+ include Base
238
266
  extend ClassMethods
239
267
 
268
+ require 'state_machine/integrations/active_model/versions'
269
+
270
+ @defaults = {}
271
+
240
272
  # 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.
273
+ # Classes that include ActiveModel::Dirty, ActiveModel::MassAssignmentSecurity,
274
+ # ActiveModel::Observing, or ActiveModel::Validations will automatically
275
+ # use the ActiveModel integration.
244
276
  def self.matches?(klass)
245
- features = %w(Dirty Observing Validations)
277
+ features = %w(Dirty MassAssignmentSecurity Observing Validations)
246
278
  defined?(::ActiveModel) && features.any? {|feature| ::ActiveModel.const_defined?(feature) && klass <= ::ActiveModel.const_get(feature)}
247
279
  end
248
280
 
249
- @defaults = {}
250
-
251
281
  # Forces the change in state to be recognized regardless of whether the
252
282
  # state value actually changed
253
- def write(object, attribute, value)
283
+ def write(object, attribute, value, *args)
254
284
  result = super
255
- if attribute == :state && supports_dirty_tracking?(object) && !object.send("#{self.attribute}_changed?")
285
+
286
+ if (attribute == :state || attribute == :event && value) && supports_dirty_tracking?(object) && !object.send("#{self.attribute}_changed?")
256
287
  object.send("#{self.attribute}_will_change!")
257
288
  end
289
+
258
290
  result
259
291
  end
260
292
 
@@ -278,6 +310,11 @@ module StateMachine
278
310
  end
279
311
 
280
312
  protected
313
+ # The name of this integration
314
+ def integration
315
+ :active_model
316
+ end
317
+
281
318
  # Whether observers are supported in the integration. Only true if
282
319
  # ActiveModel::Observer is available.
283
320
  def supports_observers?
@@ -303,14 +340,40 @@ module StateMachine
303
340
  defined?(::ActiveModel::Dirty) && owner_class <= ::ActiveModel::Dirty && object.respond_to?("#{self.attribute}_changed?")
304
341
  end
305
342
 
343
+ # Whether the protection of attributes via mass-assignment is supported
344
+ # in this integration. Only true if the ActiveModel feature is enabled
345
+ # on the owner class.
346
+ def supports_mass_assignment_security?
347
+ defined?(::ActiveModel::MassAssignmentSecurity) && owner_class <= ::ActiveModel::MassAssignmentSecurity
348
+ end
349
+
306
350
  # Gets the terminator to use for callbacks
307
351
  def callback_terminator
308
352
  @terminator ||= lambda {|result| result == false}
309
353
  end
310
354
 
311
355
  # Determines the base scope to use when looking up translations
312
- def i18n_scope
313
- owner_class.i18n_scope
356
+ def i18n_scope(klass)
357
+ klass.i18n_scope
358
+ end
359
+
360
+ # Only allows state initialization on new records that aren't being
361
+ # created with a set of attributes that includes this machine's
362
+ # attribute.
363
+ def initialize_state?(object, options)
364
+ if supports_mass_assignment_security?
365
+ attributes = (options[:attributes] || {}).dup.stringify_keys!
366
+ ignore = filter_attributes(object, attributes).keys
367
+ !ignore.map {|attribute| attribute.to_sym}.include?(attribute)
368
+ else
369
+ super
370
+ end
371
+ end
372
+
373
+ # Filters attributes that cannot be assigned through the initialization
374
+ # of the object
375
+ def filter_attributes(object, attributes)
376
+ object.send(:sanitize_for_mass_assignment, attributes)
314
377
  end
315
378
 
316
379
  # The default options to use when generating messages for validation
@@ -322,7 +385,7 @@ module StateMachine
322
385
  # Translates the given key / value combo. Translation keys are looked
323
386
  # up in the following order:
324
387
  # * <tt>#{i18n_scope}.state_machines.#{model_name}.#{machine_name}.#{plural_key}.#{value}</tt>
325
- # * <tt>#{i18n_scope}.state_machines.#{machine_name}.#{plural_key}.#{value}
388
+ # * <tt>#{i18n_scope}.state_machines.#{machine_name}.#{plural_key}.#{value}</tt>
326
389
  # * <tt>#{i18n_scope}.state_machines.#{plural_key}.#{value}</tt>
327
390
  #
328
391
  # If no keys are found, then the humanized value will be the fallback.
@@ -334,7 +397,7 @@ module StateMachine
334
397
  # Generate all possible translation keys
335
398
  translations = ancestors.map {|ancestor| :"#{ancestor.model_name.underscore}.#{name}.#{group}.#{value}"}
336
399
  translations.concat([:"#{name}.#{group}.#{value}", :"#{group}.#{value}", value.humanize.downcase])
337
- I18n.translate(translations.shift, :default => translations, :scope => [i18n_scope, :state_machines])
400
+ I18n.translate(translations.shift, :default => translations, :scope => [i18n_scope(klass), :state_machines])
338
401
  end
339
402
 
340
403
  # Build a list of ancestors for the given class to use when
@@ -343,12 +406,34 @@ module StateMachine
343
406
  klass.lookup_ancestors
344
407
  end
345
408
 
346
- # Adds the default callbacks for notifying ActiveModel observers
347
- # before/after a transition has been performed.
409
+ # Initializes class-level extensions and defaults for this machine
348
410
  def after_initialize
411
+ load_locale
412
+ load_observer_extensions
413
+ add_default_callbacks
414
+ end
415
+
416
+ # Loads any locale files needed for translating validation errors
417
+ def load_locale
418
+ I18n.load_path.unshift(locale_path) unless I18n.load_path.include?(locale_path)
419
+ end
420
+
421
+ # The path to the locale file containing state_machine translations
422
+ def locale_path
423
+ "#{File.dirname(__FILE__)}/#{integration}/locale.rb"
424
+ end
425
+
426
+ # Loads extensions to ActiveModel's Observers
427
+ def load_observer_extensions
428
+ require 'state_machine/integrations/active_model/observer'
429
+ end
430
+
431
+ # Adds a set of default callbacks that utilize the Observer extensions
432
+ def add_default_callbacks
349
433
  if supports_observers?
350
434
  callbacks[:before] << Callback.new(:before) {|object, transition| notify(:before, object, transition)}
351
435
  callbacks[:after] << Callback.new(:after) {|object, transition| notify(:after, object, transition)}
436
+ callbacks[:failure] << Callback.new(:failure) {|object, transition| notify(:after_failure_to, object, transition)}
352
437
  end
353
438
  end
354
439
 
@@ -363,15 +448,20 @@ module StateMachine
363
448
  end
364
449
 
365
450
  # Adds hooks into validation for automatically firing events
366
- def define_action_helpers(*args)
451
+ def define_action_helpers
367
452
  super
368
-
369
- action = self.action
370
- @instance_helper_module.class_eval do
371
- define_method(:valid?) do |*args|
372
- self.class.state_machines.transitions(self, action, :after => false).perform { super(*args) }
373
- end
374
- end if runs_validations_on_action?
453
+ define_validation_hook if runs_validations_on_action?
454
+ end
455
+
456
+ # Hooks into validations by defining around callbacks for the
457
+ # :validation event
458
+ def define_validation_hook
459
+ owner_class.set_callback(:validation, :around, self, :prepend => true)
460
+ end
461
+
462
+ # Runs state events around the object's validation process
463
+ def around_validation(object)
464
+ object.class.state_machines.transitions(object, action, :after => false).perform { yield }
375
465
  end
376
466
 
377
467
  # Creates a new callback in the callback chain, always inserting it
@@ -406,15 +496,15 @@ module StateMachine
406
496
  # Notifies observers on the given object that a callback occurred
407
497
  # involving the given transition. This will attempt to call the
408
498
  # following methods on observers:
409
- # * #{type}_#{qualified_event}_from_#{from}_to_#{to}
410
- # * #{type}_#{qualified_event}_from_#{from}
411
- # * #{type}_#{qualified_event}_to_#{to}
412
- # * #{type}_#{qualified_event}
413
- # * #{type}_transition_#{machine_name}_from_#{from}_to_#{to}
414
- # * #{type}_transition_#{machine_name}_from_#{from}
415
- # * #{type}_transition_#{machine_name}_to_#{to}
416
- # * #{type}_transition_#{machine_name}
417
- # * #{type}_transition
499
+ # * <tt>#{type}_#{qualified_event}_from_#{from}_to_#{to}</tt>
500
+ # * <tt>#{type}_#{qualified_event}_from_#{from}</tt>
501
+ # * <tt>#{type}_#{qualified_event}_to_#{to}</tt>
502
+ # * <tt>#{type}_#{qualified_event}</tt>
503
+ # * <tt>#{type}_transition_#{machine_name}_from_#{from}_to_#{to}</tt>
504
+ # * <tt>#{type}_transition_#{machine_name}_from_#{from}</tt>
505
+ # * <tt>#{type}_transition_#{machine_name}_to_#{to}</tt>
506
+ # * <tt>#{type}_transition_#{machine_name}</tt>
507
+ # * <tt>#{type}_transition</tt>
418
508
  #
419
509
  # This will always return true regardless of the results of the
420
510
  # callbacks.