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