mattscilipoti-state_machine 0.8.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (78) hide show
  1. data/CHANGELOG.rdoc +298 -0
  2. data/LICENSE +20 -0
  3. data/README.rdoc +474 -0
  4. data/Rakefile +98 -0
  5. data/examples/AutoShop_state.png +0 -0
  6. data/examples/Car_state.png +0 -0
  7. data/examples/TrafficLight_state.png +0 -0
  8. data/examples/Vehicle_state.png +0 -0
  9. data/examples/auto_shop.rb +11 -0
  10. data/examples/car.rb +19 -0
  11. data/examples/merb-rest/controller.rb +51 -0
  12. data/examples/merb-rest/model.rb +28 -0
  13. data/examples/merb-rest/view_edit.html.erb +24 -0
  14. data/examples/merb-rest/view_index.html.erb +23 -0
  15. data/examples/merb-rest/view_new.html.erb +13 -0
  16. data/examples/merb-rest/view_show.html.erb +17 -0
  17. data/examples/rails-rest/controller.rb +43 -0
  18. data/examples/rails-rest/migration.rb +11 -0
  19. data/examples/rails-rest/model.rb +23 -0
  20. data/examples/rails-rest/view_edit.html.erb +25 -0
  21. data/examples/rails-rest/view_index.html.erb +23 -0
  22. data/examples/rails-rest/view_new.html.erb +14 -0
  23. data/examples/rails-rest/view_show.html.erb +17 -0
  24. data/examples/traffic_light.rb +7 -0
  25. data/examples/vehicle.rb +31 -0
  26. data/init.rb +1 -0
  27. data/lib/state_machine.rb +388 -0
  28. data/lib/state_machine/assertions.rb +36 -0
  29. data/lib/state_machine/callback.rb +189 -0
  30. data/lib/state_machine/condition_proxy.rb +94 -0
  31. data/lib/state_machine/eval_helpers.rb +67 -0
  32. data/lib/state_machine/event.rb +252 -0
  33. data/lib/state_machine/event_collection.rb +122 -0
  34. data/lib/state_machine/extensions.rb +149 -0
  35. data/lib/state_machine/guard.rb +230 -0
  36. data/lib/state_machine/integrations.rb +68 -0
  37. data/lib/state_machine/integrations/active_record.rb +492 -0
  38. data/lib/state_machine/integrations/active_record/locale.rb +11 -0
  39. data/lib/state_machine/integrations/active_record/observer.rb +41 -0
  40. data/lib/state_machine/integrations/data_mapper.rb +351 -0
  41. data/lib/state_machine/integrations/data_mapper/observer.rb +139 -0
  42. data/lib/state_machine/integrations/sequel.rb +322 -0
  43. data/lib/state_machine/machine.rb +1467 -0
  44. data/lib/state_machine/machine_collection.rb +155 -0
  45. data/lib/state_machine/matcher.rb +123 -0
  46. data/lib/state_machine/matcher_helpers.rb +54 -0
  47. data/lib/state_machine/node_collection.rb +152 -0
  48. data/lib/state_machine/state.rb +249 -0
  49. data/lib/state_machine/state_collection.rb +112 -0
  50. data/lib/state_machine/tasks.rb +30 -0
  51. data/lib/state_machine/transition.rb +394 -0
  52. data/tasks/state_machine.rake +1 -0
  53. data/test/classes/switch.rb +11 -0
  54. data/test/functional/state_machine_test.rb +941 -0
  55. data/test/test_helper.rb +4 -0
  56. data/test/unit/assertions_test.rb +40 -0
  57. data/test/unit/callback_test.rb +455 -0
  58. data/test/unit/condition_proxy_test.rb +328 -0
  59. data/test/unit/eval_helpers_test.rb +120 -0
  60. data/test/unit/event_collection_test.rb +326 -0
  61. data/test/unit/event_test.rb +743 -0
  62. data/test/unit/guard_test.rb +908 -0
  63. data/test/unit/integrations/active_record_test.rb +1367 -0
  64. data/test/unit/integrations/data_mapper_test.rb +962 -0
  65. data/test/unit/integrations/sequel_test.rb +859 -0
  66. data/test/unit/integrations_test.rb +42 -0
  67. data/test/unit/invalid_event_test.rb +7 -0
  68. data/test/unit/invalid_transition_test.rb +7 -0
  69. data/test/unit/machine_collection_test.rb +938 -0
  70. data/test/unit/machine_test.rb +2004 -0
  71. data/test/unit/matcher_helpers_test.rb +37 -0
  72. data/test/unit/matcher_test.rb +155 -0
  73. data/test/unit/node_collection_test.rb +207 -0
  74. data/test/unit/state_collection_test.rb +280 -0
  75. data/test/unit/state_machine_test.rb +31 -0
  76. data/test/unit/state_test.rb +795 -0
  77. data/test/unit/transition_test.rb +1212 -0
  78. metadata +155 -0
@@ -0,0 +1,155 @@
1
+ module StateMachine
2
+ # Represents a collection of state machines for a class
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)
7
+ def initialize_states(object, options = {})
8
+ if ignore = options[:ignore]
9
+ ignore.map! {|attribute| attribute.to_sym}
10
+ end
11
+
12
+ 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.write(object, :state, machine.initial_state(object).value) if ignore || value.nil? || value.respond_to?(:empty?) && value.empty?
16
+ end
17
+ end
18
+ end
19
+
20
+ # Runs one or more events in parallel on the given object. See
21
+ # StateMachine::InstanceMethods#fire_events for more information.
22
+ def fire_events(object, *events)
23
+ run_action = [true, false].include?(events.last) ? events.pop : true
24
+
25
+ # Generate the transitions to run for each event
26
+ transitions = events.collect do |event_name|
27
+ # Find the actual event being run
28
+ event = nil
29
+ detect do |name, machine|
30
+ event = machine.events[event_name, :qualified_name]
31
+ end
32
+
33
+ raise InvalidEvent, "#{event_name.inspect} is an unknown state machine event" unless event
34
+
35
+ # Get the transition that will be performed for the event
36
+ unless transition = event.transition_for(object)
37
+ machine = event.machine
38
+ machine.invalidate(object, :state, :invalid_transition, [[:event, event_name]])
39
+ end
40
+
41
+ transition
42
+ end.compact
43
+
44
+ # Run the events in parallel only if valid transitions were found for
45
+ # all of them
46
+ if events.length == transitions.length
47
+ Transition.perform_within_transaction(transitions, :action => run_action)
48
+ else
49
+ false
50
+ end
51
+ end
52
+
53
+ # Runs one or more event attributes in parallel during the invocation of
54
+ # an action on the given object. after_transition callbacks can be
55
+ # optionally disabled if the events are being only partially fired (for
56
+ # example, when validating records in ORM integrations).
57
+ #
58
+ # The event attributes that will be fired are based on which machines
59
+ # match the action that is being invoked.
60
+ #
61
+ # == Examples
62
+ #
63
+ # class Vehicle
64
+ # include DataMapper::Resource
65
+ # property :id, Serial
66
+ #
67
+ # state_machine :initial => :parked do
68
+ # event :ignite do
69
+ # transition :parked => :idling
70
+ # end
71
+ # end
72
+ #
73
+ # state_machine :alarm_state, :namespace => 'alarm', :initial => :active do
74
+ # event :disable do
75
+ # transition all => :off
76
+ # end
77
+ # end
78
+ # end
79
+ #
80
+ # With valid events:
81
+ #
82
+ # vehicle = Vehicle.create # => #<Vehicle id=1 state="parked" alarm_state="active">
83
+ # vehicle.state_event = 'ignite'
84
+ # vehicle.alarm_state_event = 'disable'
85
+ #
86
+ # Vehicle.state_machines.fire_event_attributes(vehicle, :save) { true }
87
+ # vehicle.state # => "idling"
88
+ # vehicle.state_event # => nil
89
+ # vehicle.alarm_state # => "off"
90
+ # vehicle.alarm_state_event # => nil
91
+ #
92
+ # With invalid events:
93
+ #
94
+ # vehicle = Vehicle.create # => #<Vehicle id=1 state="parked" alarm_state="active">
95
+ # vehicle.state_event = 'park'
96
+ # vehicle.alarm_state_event = 'disable'
97
+ #
98
+ # Vehicle.state_machines.fire_event_attributes(vehicle, :save) { true }
99
+ # vehicle.state # => "parked"
100
+ # vehicle.state_event # => nil
101
+ # vehicle.alarm_state # => "active"
102
+ # vehicle.alarm_state_event # => nil
103
+ # vehicle.errors # => #<DataMapper::Validate::ValidationErrors:0xb7af9abc @errors={"state_event"=>["is invalid"]}>
104
+ #
105
+ # With partial firing:
106
+ #
107
+ # vehicle = Vehicle.create # => #<Vehicle id=1 state="parked" alarm_state="active">
108
+ # vehicle.state_event = 'ignite'
109
+ #
110
+ # Vehicle.state_machines.fire_event_attributes(vehicle, :save, false) { true }
111
+ # vehicle.state # => "idling"
112
+ # vehicle.state_event # => "ignite"
113
+ # vehicle.state_event_transition # => #<StateMachine::Transition attribute=:state event=:ignite from="parked" from_name=:parked to="idling" to_name=:idling>
114
+ def fire_event_attributes(object, action, complete = true)
115
+ # Get the transitions to fire for each applicable machine
116
+ transitions = map {|name, machine| machine.action == action ? machine.events.attribute_transition_for(object, true) : nil}.compact
117
+ return yield if transitions.empty?
118
+
119
+ # The value generated by the yielded block (the actual action)
120
+ action_value = nil
121
+
122
+ # Make sure all events were valid
123
+ if result = transitions.all? {|transition| transition != false}
124
+ # Clear any traces of the event since transitions are available and to
125
+ # prevent from being evaluated multiple times if actions are nested
126
+ transitions.each do |transition|
127
+ transition.machine.write(object, :event, nil)
128
+ transition.machine.write(object, :event_transition, nil)
129
+ end
130
+
131
+ # Perform the transitions
132
+ begin
133
+ result = Transition.perform(transitions, :after => complete) { action_value = yield }
134
+ rescue Exception
135
+ # Reset the event attribute so it can be re-evaluated if attempted again
136
+ transitions.each do |transition|
137
+ transition.machine.write(object, :event, transition.event)
138
+ end
139
+
140
+ raise
141
+ end
142
+
143
+ transitions.each do |transition|
144
+ # Revert event if failed (to allow for more attempts)
145
+ transition.machine.write(object, :event, transition.event) unless result
146
+
147
+ # Track transition if partial transition was successful
148
+ transition.machine.write(object, :event_transition, transition) if !complete && result
149
+ end
150
+ end
151
+
152
+ action_value.nil? ? result : action_value
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,123 @@
1
+ require 'singleton'
2
+
3
+ module StateMachine
4
+ # Provides a general strategy pattern for determining whether a match is found
5
+ # for a value. The algorithm that actually determines the match depends on
6
+ # the matcher in use.
7
+ class Matcher
8
+ # The list of values against which queries are matched
9
+ attr_reader :values
10
+
11
+ # Creates a new matcher for querying against the given set of values
12
+ def initialize(values = [])
13
+ @values = values.is_a?(Array) ? values : [values]
14
+ end
15
+
16
+ # Generates a subset of values that exists in both the set of values being
17
+ # filtered and the values configured for the matcher
18
+ def filter(values)
19
+ self.values & values
20
+ end
21
+ end
22
+
23
+ # Matches any given value. Since there is no configuration for this type of
24
+ # matcher, it must be used as a singleton.
25
+ class AllMatcher < Matcher
26
+ include Singleton
27
+
28
+ # Generates a blacklist matcher based on the given set of values
29
+ #
30
+ # == Examples
31
+ #
32
+ # matcher = StateMachine::AllMatcher.instance - [:parked, :idling]
33
+ # matcher.matches?(:parked) # => false
34
+ # matcher.matches?(:first_gear) # => true
35
+ def -(blacklist)
36
+ BlacklistMatcher.new(blacklist)
37
+ end
38
+
39
+ # Always returns true
40
+ def matches?(value, context = {})
41
+ true
42
+ end
43
+
44
+ # Always returns the given set of values
45
+ def filter(values)
46
+ values
47
+ end
48
+
49
+ # A human-readable description of this matcher. Always "all".
50
+ def description
51
+ 'all'
52
+ end
53
+ end
54
+
55
+ # Matches a specific set of values
56
+ class WhitelistMatcher < Matcher
57
+ # Checks whether the given value exists within the whitelist configured
58
+ # for this matcher.
59
+ #
60
+ # == Examples
61
+ #
62
+ # matcher = StateMachine::WhitelistMatcher.new([:parked, :idling])
63
+ # matcher.matches?(:parked) # => true
64
+ # matcher.matches?(:first_gear) # => false
65
+ def matches?(value, context = {})
66
+ values.include?(value)
67
+ end
68
+
69
+ # A human-readable description of this matcher
70
+ def description
71
+ values.length == 1 ? values.first.inspect : values.inspect
72
+ end
73
+ end
74
+
75
+ # Matches everything but a specific set of values
76
+ class BlacklistMatcher < Matcher
77
+ # Checks whether the given value exists outside the blacklist configured
78
+ # for this matcher.
79
+ #
80
+ # == Examples
81
+ #
82
+ # matcher = StateMachine::BlacklistMatcher.new([:parked, :idling])
83
+ # matcher.matches?(:parked) # => false
84
+ # matcher.matches?(:first_gear) # => true
85
+ def matches?(value, context = {})
86
+ !values.include?(value)
87
+ end
88
+
89
+ # Finds all values that are *not* within the blacklist configured for this
90
+ # matcher
91
+ def filter(values)
92
+ values - self.values
93
+ end
94
+
95
+ # A human-readable description of this matcher
96
+ def description
97
+ "all - #{values.length == 1 ? values.first.inspect : values.inspect}"
98
+ end
99
+ end
100
+
101
+ # Matches a loopback of two values within a context. Since there is no
102
+ # configuration for this type of matcher, it must be used as a singleton.
103
+ class LoopbackMatcher < Matcher
104
+ include Singleton
105
+
106
+ # Checks whether the given value matches what the value originally was.
107
+ # This value should be defined in the context.
108
+ #
109
+ # == Examples
110
+ #
111
+ # matcher = StateMachine::LoopbackMatcher.instance
112
+ # matcher.matches?(:parked, :from => :parked) # => true
113
+ # matcher.matches?(:parked, :from => :idling) # => false
114
+ def matches?(value, context)
115
+ context[:from] == value
116
+ end
117
+
118
+ # A human-readable description of this matcher. Always "same".
119
+ def description
120
+ 'same'
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,54 @@
1
+ module StateMachine
2
+ # Provides a set of helper methods for generating matchers
3
+ module MatcherHelpers
4
+ # Represents a state that matches all known states in a machine.
5
+ #
6
+ # == Examples
7
+ #
8
+ # class Vehicle
9
+ # state_machine do
10
+ # before_transition any => :parked, :do => lambda {...}
11
+ # before_transition all - :parked => all - :idling, :do => lambda {}
12
+ #
13
+ # event :park
14
+ # transition all => :parked
15
+ # end
16
+ #
17
+ # event :crash
18
+ # transition all - :parked => :stalled
19
+ # end
20
+ # end
21
+ # end
22
+ #
23
+ # In the above example, +all+ will match the following states since they
24
+ # are known:
25
+ # * +parked+
26
+ # * +stalled+
27
+ # * +idling+
28
+ def all
29
+ AllMatcher.instance
30
+ end
31
+ alias_method :any, :all
32
+
33
+ # Represents a state that matches the original +from+ state. This is useful
34
+ # for defining transitions which are loopbacks.
35
+ #
36
+ # == Examples
37
+ #
38
+ # class Vehicle
39
+ # state_machine do
40
+ # event :ignite
41
+ # transition [:idling, :first_gear] => same
42
+ # end
43
+ # end
44
+ # end
45
+ #
46
+ # In the above example, +same+ will match whichever the from state is. In
47
+ # the case of the +ignite+ event, it is essential the same as the following:
48
+ #
49
+ # transition :parked => :parked, :first_gear => :first_gear
50
+ def same
51
+ LoopbackMatcher.instance
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,152 @@
1
+ require 'state_machine/assertions'
2
+
3
+ module StateMachine
4
+ # Represents a collection of nodes in a state machine, be it events or states.
5
+ class NodeCollection
6
+ include Enumerable
7
+ include Assertions
8
+
9
+ # The machine associated with the nodes
10
+ attr_reader :machine
11
+
12
+ # Creates a new collection of nodes for the given state machine. By default,
13
+ # the collection is empty.
14
+ #
15
+ # Configuration options:
16
+ # * <tt>:index</tt> - One or more attributes to automatically generate
17
+ # hashed indices for in order to perform quick lookups. Default is to
18
+ # index by the :name attribute
19
+ def initialize(machine, options = {})
20
+ assert_valid_keys(options, :index)
21
+ options = {:index => :name}.merge(options)
22
+
23
+ @machine = machine
24
+ @nodes = []
25
+ @indices = Array(options[:index]).inject({}) {|indices, attribute| indices[attribute] = {}; indices}
26
+ @default_index = Array(options[:index]).first
27
+ end
28
+
29
+ # Creates a copy of this collection such that modifications don't affect
30
+ # the original collection
31
+ def initialize_copy(orig) #:nodoc:
32
+ super
33
+
34
+ nodes = @nodes
35
+ @nodes = []
36
+ @indices = @indices.inject({}) {|indices, (name, index)| indices[name] = {}; indices}
37
+ nodes.each {|node| self << node.dup}
38
+ end
39
+
40
+ # Changes the current machine associated with the collection. In turn, this
41
+ # will change the state machine associated with each node in the collection.
42
+ def machine=(new_machine)
43
+ @machine = new_machine
44
+ each {|node| node.machine = new_machine}
45
+ end
46
+
47
+ # Gets the number of nodes in this collection
48
+ def length
49
+ @nodes.length
50
+ end
51
+
52
+ # Gets the set of unique keys for the given index
53
+ def keys(index_name = @default_index)
54
+ index(index_name).keys
55
+ end
56
+
57
+ # Adds a new node to the collection. By doing so, this will also add it to
58
+ # the configured indices.
59
+ def <<(node)
60
+ @nodes << node
61
+ @indices.each {|attribute, index| index[value(node, attribute)] = node}
62
+ self
63
+ end
64
+
65
+ # Updates the indexed keys for the given node. If the node's attribute
66
+ # has changed since it was added to the collection, the old indexed keys
67
+ # will be replaced with the updated ones.
68
+ def update(node)
69
+ @indices.each do |attribute, index|
70
+ old_key = RUBY_VERSION < '1.9' ? index.index(node) : index.key(node)
71
+ new_key = value(node, attribute)
72
+
73
+ # Only replace the key if it's changed
74
+ if old_key != new_key
75
+ index.delete(old_key)
76
+ index[new_key] = node
77
+ end
78
+ end
79
+ end
80
+
81
+ # Calls the block once for each element in self, passing that element as a
82
+ # parameter.
83
+ #
84
+ # states = StateMachine::NodeCollection.new
85
+ # states << StateMachine::State.new(machine, :parked)
86
+ # states << StateMachine::State.new(machine, :idling)
87
+ # states.each {|state| puts state.name, ' -- '}
88
+ #
89
+ # ...produces:
90
+ #
91
+ # parked -- idling --
92
+ def each
93
+ @nodes.each {|node| yield node}
94
+ self
95
+ end
96
+
97
+ # Gets the node at the given index.
98
+ #
99
+ # states = StateMachine::NodeCollection.new
100
+ # states << StateMachine::State.new(machine, :parked)
101
+ # states << StateMachine::State.new(machine, :idling)
102
+ #
103
+ # states.at(0).name # => :parked
104
+ # states.at(1).name # => :idling
105
+ def at(index)
106
+ @nodes[index]
107
+ end
108
+
109
+ # Gets the node indexed by the given key. By default, this will look up the
110
+ # key in the first index configured for the collection. A custom index can
111
+ # be specified like so:
112
+ #
113
+ # collection['parked', :value]
114
+ #
115
+ # The above will look up the "parked" key in a hash indexed by each node's
116
+ # +value+ attribute.
117
+ #
118
+ # If the key cannot be found, then nil will be returned.
119
+ def [](key, index_name = @default_index)
120
+ index(index_name)[key]
121
+ end
122
+
123
+ # Gets the node indexed by the given key. By default, this will look up the
124
+ # key in the first index configured for the collection. A custom index can
125
+ # be specified like so:
126
+ #
127
+ # collection['parked', :value]
128
+ #
129
+ # The above will look up the "parked" key in a hash indexed by each node's
130
+ # +value+ attribute.
131
+ #
132
+ # If the key cannot be found, then an IndexError exception will be raised:
133
+ #
134
+ # collection['invalid', :value] # => IndexError: "invalid" is an invalid value
135
+ def fetch(key, index_name = @default_index)
136
+ self[key, index_name] || raise(IndexError, "#{key.inspect} is an invalid #{index_name}")
137
+ end
138
+
139
+ private
140
+ # Gets the given index. If the index does not exist, then an ArgumentError
141
+ # is raised.
142
+ def index(name)
143
+ raise ArgumentError, 'No indices configured' unless @indices.any?
144
+ @indices[name] || raise(ArgumentError, "Invalid index: #{name.inspect}")
145
+ end
146
+
147
+ # Gets the value for the given attribute on the node
148
+ def value(node, attribute)
149
+ node.send(attribute)
150
+ end
151
+ end
152
+ end