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,20 +1,28 @@
1
1
  module StateMachine
2
2
  # Represents a collection of state machines for a class
3
3
  class MachineCollection < Hash
4
- # Initializes the state of each machine in the given object. Initial
5
- # values are only set if the machine's attribute doesn't already exist
6
- # (which must mean the defaults are being skipped)
4
+ # Initializes the state of each machine in the given object. This can allow
5
+ # states to be initialized in two groups: static and dynamic. For example:
6
+ #
7
+ # machines.initialize_states(object) do
8
+ # # After static state initialization, before dynamic state initialization
9
+ # end
10
+ #
11
+ # If no block is provided, then all states will still be initialized.
7
12
  def initialize_states(object, options = {})
8
- if ignore = options[:ignore]
9
- ignore = ignore.map {|attribute| attribute.to_sym}
10
- end
13
+ options = {:static => true, :dynamic => true}.merge(options)
14
+
15
+ each_value do |machine|
16
+ machine.initialize_state(object, options) unless machine.dynamic_initial_state?
17
+ end if options[:static]
18
+
19
+ result = yield if block_given?
11
20
 
12
21
  each_value do |machine|
13
- if (!ignore || !ignore.include?(machine.attribute)) && (!options.include?(:dynamic) || machine.dynamic_initial_state? == options[:dynamic])
14
- value = machine.read(object, :state)
15
- machine.initialize_state(object) if ignore || value.nil? || value.respond_to?(:empty?) && value.empty?
16
- end
17
- end
22
+ machine.initialize_state(object, options) if machine.dynamic_initial_state?
23
+ end if options[:dynamic]
24
+
25
+ result
18
26
  end
19
27
 
20
28
  # Runs one or more events in parallel on the given object. See
@@ -28,12 +36,12 @@ module StateMachine
28
36
  event = nil
29
37
  detect {|name, machine| event = machine.events[event_name, :qualified_name]}
30
38
 
31
- raise InvalidEvent, "#{event_name.inspect} is an unknown state machine event" unless event
39
+ raise(InvalidEvent.new(object, event_name)) unless event
32
40
 
33
41
  # Get the transition that will be performed for the event
34
42
  unless transition = event.transition_for(object)
35
43
  machine = event.machine
36
- machine.invalidate(object, :state, :invalid_transition, [[:event, event.human_name]])
44
+ event.on_failure(object)
37
45
  end
38
46
 
39
47
  transition
@@ -34,7 +34,7 @@ module StateMachine
34
34
  nodes = @nodes
35
35
  @nodes = []
36
36
  @indices = @indices.inject({}) {|indices, (name, index)| indices[name] = {}; indices}
37
- nodes.each {|node| self << node.dup}
37
+ concat(nodes.map {|n| n.dup})
38
38
  end
39
39
 
40
40
  # Changes the current machine associated with the collection. In turn, this
@@ -62,6 +62,11 @@ module StateMachine
62
62
  self
63
63
  end
64
64
 
65
+ # Appends a group of nodes to the collection
66
+ def concat(nodes)
67
+ nodes.each {|node| self << node}
68
+ end
69
+
65
70
  # Updates the indexed keys for the given node. If the node's attribute
66
71
  # has changed since it was added to the collection, the old indexed keys
67
72
  # will be replaced with the updated ones.
@@ -0,0 +1,120 @@
1
+ module StateMachine
2
+ # A path represents a sequence of transitions that can be run for a particular
3
+ # object. Paths can walk to new transitions, revealing all of the possible
4
+ # branches that can be encountered in the object's state machine.
5
+ class Path < Array
6
+ include Assertions
7
+
8
+ # The object whose state machine is being walked
9
+ attr_reader :object
10
+
11
+ # The state machine this path is walking
12
+ attr_reader :machine
13
+
14
+ # Creates a new transition path for the given object. Initially this is an
15
+ # empty path. In order to start walking the path, it must be populated with
16
+ # an initial transition.
17
+ #
18
+ # Configuration options:
19
+ # * <tt>:target</tt> - The target state to end the path on
20
+ # * <tt>:guard</tt> - Whether to guard transitions with the if/unless
21
+ # conditionals defined for each one
22
+ def initialize(object, machine, options = {})
23
+ assert_valid_keys(options, :target, :guard)
24
+
25
+ @object = object
26
+ @machine = machine
27
+ @target = options[:target]
28
+ @guard = options[:guard]
29
+ end
30
+
31
+ def initialize_copy(orig) #:nodoc:
32
+ super
33
+ @transitions = nil
34
+ end
35
+
36
+ # The initial state name for this path
37
+ def from_name
38
+ first && first.from_name
39
+ end
40
+
41
+ # Lists all of the from states that can be reached through this path.
42
+ #
43
+ # For example,
44
+ #
45
+ # path.to_states # => [:parked, :idling, :first_gear, ...]
46
+ def from_states
47
+ map {|transition| transition.from_name}.uniq
48
+ end
49
+
50
+ # The end state name for this path. If a target state was specified for
51
+ # the path, then that will be returned if the path is complete.
52
+ def to_name
53
+ last && last.to_name
54
+ end
55
+
56
+ # Lists all of the to states that can be reached through this path.
57
+ #
58
+ # For example,
59
+ #
60
+ # path.to_states # => [:parked, :idling, :first_gear, ...]
61
+ def to_states
62
+ map {|transition| transition.to_name}.uniq
63
+ end
64
+
65
+ # Lists all of the events that can be fired through this path.
66
+ #
67
+ # For example,
68
+ #
69
+ # path.events # => [:park, :ignite, :shift_up, ...]
70
+ def events
71
+ map {|transition| transition.event}.uniq
72
+ end
73
+
74
+ # Walks down the next transitions at the end of this path. This will only
75
+ # walk down paths that are considered valid.
76
+ def walk
77
+ transitions.each {|transition| yield dup.push(transition)}
78
+ end
79
+
80
+ # Determines whether or not this path has completed. A path is considered
81
+ # complete when one of the following conditions is met:
82
+ # * The last transition in the path ends on the target state
83
+ # * There are no more transitions remaining to walk and there is no target
84
+ # state
85
+ def complete?
86
+ !empty? && (@target ? to_name == @target : transitions.empty?)
87
+ end
88
+
89
+ private
90
+ # Calculates the number of times the given state has been walked to
91
+ def times_walked_to(state)
92
+ select {|transition| transition.to_name == state}.length
93
+ end
94
+
95
+ # Determines whether the given transition has been recently walked down in
96
+ # this path. If a target is configured for this path, then this will only
97
+ # look at transitions walked down since the target was last reached.
98
+ def recently_walked?(transition)
99
+ transitions = self
100
+ if @target && @target != to_name && target_transition = detect {|t| t.to_name == @target}
101
+ transitions = transitions[index(target_transition) + 1..-1]
102
+ end
103
+ transitions.include?(transition)
104
+ end
105
+
106
+ # Determines whether it's possible to walk to the given transition from
107
+ # the current path. A transition can be walked to if:
108
+ # * It has not been recently walked and
109
+ # * If a target is specified, it has not been walked to twice yet
110
+ def can_walk_to?(transition)
111
+ !recently_walked?(transition) && (!@target || times_walked_to(@target) < 2)
112
+ end
113
+
114
+ # Get the next set of transitions that can be walked to starting from the
115
+ # end of this path
116
+ def transitions
117
+ @transitions ||= empty? ? [] : machine.events.transitions_for(object, :from => to_name, :guard => @guard).select {|transition| can_walk_to?(transition)}
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,90 @@
1
+ require 'state_machine/path'
2
+
3
+ module StateMachine
4
+ # Represents a collection of paths that are generated based on a set of
5
+ # requirements regarding what states to start and end on
6
+ class PathCollection < Array
7
+ include Assertions
8
+
9
+ # The object whose state machine is being walked
10
+ attr_reader :object
11
+
12
+ # The state machine these path are walking
13
+ attr_reader :machine
14
+
15
+ # The initial state to start each path from
16
+ attr_reader :from_name
17
+
18
+ # The target state for each path
19
+ attr_reader :to_name
20
+
21
+ # Creates a new collection of paths with the given requirements.
22
+ #
23
+ # Configuration options:
24
+ # * <tt>:from</tt> - The initial state to start from
25
+ # * <tt>:to</tt> - The target end state
26
+ # * <tt>:deep</tt> - Whether to enable deep searches for the target state.
27
+ # * <tt>:guard</tt> - Whether to guard transitions with the if/unless
28
+ # conditionals defined for each one
29
+ def initialize(object, machine, options = {})
30
+ options = {:deep => false, :from => machine.states.match!(object).name}.merge(options)
31
+ assert_valid_keys(options, :from, :to, :deep, :guard)
32
+
33
+ @object = object
34
+ @machine = machine
35
+ @from_name = machine.states.fetch(options[:from]).name
36
+ @to_name = options[:to] && machine.states.fetch(options[:to]).name
37
+ @guard = options[:guard]
38
+ @deep = options[:deep]
39
+
40
+ initial_paths.each {|path| walk(path)}
41
+ end
42
+
43
+ # Lists all of the states that can be transitioned from through the paths in
44
+ # this collection.
45
+ #
46
+ # For example,
47
+ #
48
+ # paths.from_states # => [:parked, :idling, :first_gear, ...]
49
+ def from_states
50
+ map {|path| path.from_states}.flatten.uniq
51
+ end
52
+
53
+ # Lists all of the states that can be transitioned to through the paths in
54
+ # this collection.
55
+ #
56
+ # For example,
57
+ #
58
+ # paths.to_states # => [:idling, :first_gear, :second_gear, ...]
59
+ def to_states
60
+ map {|path| path.to_states}.flatten.uniq
61
+ end
62
+
63
+ # Lists all of the events that can be fired through the paths in this
64
+ # collection.
65
+ #
66
+ # For example,
67
+ #
68
+ # paths.events # => [:park, :ignite, :shift_up, ...]
69
+ def events
70
+ map {|path| path.events}.flatten.uniq
71
+ end
72
+
73
+ private
74
+ # Gets the initial set of paths to walk
75
+ def initial_paths
76
+ machine.events.transitions_for(object, :from => from_name, :guard => @guard).map do |transition|
77
+ path = Path.new(object, machine, :target => to_name, :guard => @guard)
78
+ path << transition
79
+ path
80
+ end
81
+ end
82
+
83
+ # Walks down the given path. Each new path that matches the configured
84
+ # requirements will be added to this collection.
85
+ def walk(path)
86
+ self << path if path.complete?
87
+ path.walk {|next_path| walk(next_path)} unless to_name && path.complete? && !@deep
88
+ end
89
+ end
90
+ end
@@ -73,7 +73,20 @@ module StateMachine
73
73
  @methods = {}
74
74
  @initial = options[:initial] == true
75
75
 
76
- add_predicate
76
+ if name
77
+ conflicting_machines = machine.owner_class.state_machines.select {|name, other_machine| other_machine != machine && other_machine.states[qualified_name, :qualified_name]}
78
+
79
+ # Output a warning if another machine has a conflicting qualified name
80
+ # for a different attribute
81
+ if conflict = conflicting_machines.detect {|name, other_machine| other_machine.attribute != machine.attribute}
82
+ name, other_machine = conflict
83
+ warn "State #{qualified_name.inspect} for #{machine.name.inspect} is already defined in #{other_machine.name.inspect}"
84
+ elsif conflicting_machines.empty?
85
+ # Only bother adding predicates when another machine for the same
86
+ # attribute hasn't already done so
87
+ add_predicate
88
+ end
89
+ end
77
90
  end
78
91
 
79
92
  # Creates a copy of this state in addition to the list of associated
@@ -89,8 +102,8 @@ module StateMachine
89
102
  # machine's definition.
90
103
  def final?
91
104
  !machine.events.any? do |event|
92
- event.guards.any? do |guard|
93
- guard.state_requirements.any? do |requirement|
105
+ event.branches.any? do |branch|
106
+ branch.state_requirements.any? do |requirement|
94
107
  requirement[:from].matches?(name) && !requirement[:to].matches?(name, :from => name)
95
108
  end
96
109
  end
@@ -247,13 +260,19 @@ module StateMachine
247
260
  end
248
261
 
249
262
  # Adds a predicate method to the owner class so long as a name has
250
- # actually been configured for the state
263
+ # actually been configured for the state and the method isn't already
264
+ # defined in the owner class.
251
265
  def add_predicate
252
- return unless name
253
-
254
- # Checks whether the current value matches this state
255
- machine.define_instance_method("#{qualified_name}?") do |machine, object|
256
- machine.states.matches?(object, name)
266
+ owner_class = machine.owner_class
267
+ predicate = "#{qualified_name}?"
268
+ if !owner_class.method_defined?(predicate) && !owner_class.private_method_defined?(predicate)
269
+ # Checks whether the current value matches this state
270
+ machine.define_helper(:instance, predicate) do |machine, object|
271
+ machine.states.matches?(object, name)
272
+ end
273
+ else
274
+ # Only output a warning since we can't defined the predicate
275
+ warn "#{owner_class.name}##{predicate} is already defined, use #{owner_class.name}##{machine.name}?(:#{name}) instead."
257
276
  end
258
277
  end
259
278
  end
@@ -4,7 +4,7 @@ module StateMachine
4
4
  # Represents a collection of states in a state machine
5
5
  class StateCollection < NodeCollection
6
6
  def initialize(machine) #:nodoc:
7
- super(machine, :index => [:name, :value])
7
+ super(machine, :index => [:name, :qualified_name, :value])
8
8
  end
9
9
 
10
10
  # Determines whether the given object is in a specific state. If the
@@ -1,8 +1,55 @@
1
1
  require 'state_machine/transition_collection'
2
+ require 'state_machine/error'
2
3
 
3
4
  module StateMachine
4
5
  # An invalid transition was attempted
5
- class InvalidTransition < StandardError
6
+ class InvalidTransition < Error
7
+ # The machine attempting to be transitioned
8
+ attr_reader :machine
9
+
10
+ # The current state value for the machine
11
+ attr_reader :from
12
+
13
+ def initialize(object, machine, event) #:nodoc:
14
+ @machine = machine
15
+ @from_state = machine.states.match!(object)
16
+ @from = machine.read(object, :state)
17
+ @event = machine.events.fetch(event)
18
+
19
+ super(object, "Cannot transition #{machine.name} via :#{self.event} from #{from_name.inspect}")
20
+ end
21
+
22
+ # The event that triggered the failed transition
23
+ def event
24
+ @event.name
25
+ end
26
+
27
+ # The fully-qualified name of the event that triggered the failed transition
28
+ def qualified_event
29
+ @event.qualified_name
30
+ end
31
+
32
+ # The name for the current state
33
+ def from_name
34
+ @from_state.name
35
+ end
36
+
37
+ # The fully-qualified name for the current state
38
+ def qualified_from_name
39
+ @from_state.qualified_name
40
+ end
41
+ end
42
+
43
+ # A set of transition failed to run in parallel
44
+ class InvalidParallelTransition < Error
45
+ # The set of events that failed the transition(s)
46
+ attr_reader :events
47
+
48
+ def initialize(object, events) #:nodoc:
49
+ @events = events
50
+
51
+ super(object, "Cannot run events in parallel: #{events * ', '}")
52
+ end
6
53
  end
7
54
 
8
55
  # A transition represents a state change for a specific attribute.
@@ -175,6 +222,7 @@ module StateMachine
175
222
  # provided, then it will be executed between the before and after callbacks.
176
223
  #
177
224
  # Configuration options:
225
+ # * +before+ - Whether to run before callbacks.
178
226
  # * +after+ - Whether to run after callbacks. If false, then any around
179
227
  # callbacks will be paused until called again with +after+ enabled.
180
228
  # Default is true.
@@ -182,10 +230,10 @@ module StateMachine
182
230
  # This will return true if all before callbacks gets executed. After
183
231
  # callbacks will not have an effect on the result.
184
232
  def run_callbacks(options = {}, &block)
185
- options = {:after => true}.merge(options)
233
+ options = {:before => true, :after => true}.merge(options)
186
234
  @success = false
187
235
 
188
- halted = pausable { before(options[:after], &block) }
236
+ halted = pausable { before(options[:after], &block) } if options[:before]
189
237
 
190
238
  # After callbacks are only run if:
191
239
  # * An around callback didn't halt after yielding
@@ -257,6 +305,17 @@ module StateMachine
257
305
  @paused_block = nil
258
306
  end
259
307
 
308
+ # Determines equality of transitions by testing whether the object, states,
309
+ # and event involved in the transition are equal
310
+ def ==(other)
311
+ other.instance_of?(self.class) &&
312
+ other.object == object &&
313
+ other.machine == machine &&
314
+ other.from_name == from_name &&
315
+ other.to_name == to_name &&
316
+ other.event == event
317
+ end
318
+
260
319
  # Generates a nicely formatted description of this transitions's contents.
261
320
  #
262
321
  # For example,
@@ -341,7 +400,7 @@ module StateMachine
341
400
  before(complete, index, &block)
342
401
 
343
402
  pause if @success && !complete
344
- throw :cancel, true unless callback.matches_success?(@success)
403
+ throw :cancel, true unless @success
345
404
  end
346
405
  end
347
406
  else
@@ -375,8 +434,8 @@ module StateMachine
375
434
  # First resume previously paused callbacks
376
435
  if resume
377
436
  catch(:halt) do
378
- after_context = context.merge(:success => @success)
379
- machine.callbacks[:after].each {|callback| callback.call(object, after_context, self)}
437
+ type = @success ? :after : :failure
438
+ machine.callbacks[type].each {|callback| callback.call(object, context, self)}
380
439
  end
381
440
  end
382
441