state_machine 0.9.4 → 0.10.0

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