pluginaweek-state_machine 0.7.6

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 (78) hide show
  1. data/CHANGELOG.rdoc +273 -0
  2. data/LICENSE +20 -0
  3. data/README.rdoc +466 -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 +429 -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 +251 -0
  33. data/lib/state_machine/event_collection.rb +113 -0
  34. data/lib/state_machine/extensions.rb +158 -0
  35. data/lib/state_machine/guard.rb +219 -0
  36. data/lib/state_machine/integrations.rb +68 -0
  37. data/lib/state_machine/integrations/active_record.rb +444 -0
  38. data/lib/state_machine/integrations/active_record/locale.rb +10 -0
  39. data/lib/state_machine/integrations/active_record/observer.rb +41 -0
  40. data/lib/state_machine/integrations/data_mapper.rb +325 -0
  41. data/lib/state_machine/integrations/data_mapper/observer.rb +139 -0
  42. data/lib/state_machine/integrations/sequel.rb +292 -0
  43. data/lib/state_machine/machine.rb +1431 -0
  44. data/lib/state_machine/machine_collection.rb +146 -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/transition.rb +367 -0
  51. data/tasks/state_machine.rake +1 -0
  52. data/tasks/state_machine.rb +30 -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 +129 -0
  60. data/test/unit/event_collection_test.rb +293 -0
  61. data/test/unit/event_test.rb +605 -0
  62. data/test/unit/guard_test.rb +862 -0
  63. data/test/unit/integrations/active_record_test.rb +1001 -0
  64. data/test/unit/integrations/data_mapper_test.rb +694 -0
  65. data/test/unit/integrations/sequel_test.rb +486 -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 +710 -0
  70. data/test/unit/machine_test.rb +1910 -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 +1113 -0
  78. metadata +161 -0
@@ -0,0 +1,146 @@
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)
8
+ each do |attribute, machine|
9
+ value = machine.read(object, :state)
10
+ machine.write(object, :state, machine.initial_state(object).value) if value.nil? || value.respond_to?(:empty?) && value.empty?
11
+ end
12
+ end
13
+
14
+ # Runs one or more events in parallel on the given object. See
15
+ # StateMachine::InstanceMethods#fire_events for more information.
16
+ def fire_events(object, *events)
17
+ run_action = [true, false].include?(events.last) ? events.pop : true
18
+
19
+ # Generate the transitions to run for each event
20
+ transitions = events.collect do |name|
21
+ # Find the actual event being run
22
+ event = nil
23
+ detect do |attribute, machine|
24
+ event = machine.events[name, :qualified_name]
25
+ end
26
+
27
+ raise InvalidEvent, "#{name.inspect} is an unknown state machine event" unless event
28
+
29
+ # Get the transition that will be performed for the event
30
+ unless transition = event.transition_for(object)
31
+ machine = event.machine
32
+ machine.invalidate(object, machine.attribute, :invalid_transition, [[:event, name]])
33
+ end
34
+
35
+ transition
36
+ end.compact
37
+
38
+ # Run the events in parallel only if valid transitions were found for
39
+ # all of them
40
+ if events.length == transitions.length
41
+ Transition.perform_within_transaction(transitions, :action => run_action)
42
+ else
43
+ false
44
+ end
45
+ end
46
+
47
+ # Runs one or more event attributes in parallel during the invocation of
48
+ # an action on the given object. After transition callbacks can be
49
+ # optionally disabled if the events are being only partially fired (for
50
+ # example, when validating records in ORM integrations).
51
+ #
52
+ # The event attributes that will be fired are based on which machines
53
+ # match the action that is being invoked.
54
+ #
55
+ # == Examples
56
+ #
57
+ # class Vehicle
58
+ # include DataMapper::Resource
59
+ # property :id, Integer, :serial => true
60
+ #
61
+ # state_machine :initial => :parked do
62
+ # event :ignite do
63
+ # transition :parked => :idling
64
+ # end
65
+ # end
66
+ #
67
+ # state_machine :alarm_state, :namespace => 'alarm', :initial => :active do
68
+ # event :disable do
69
+ # transition all => :off
70
+ # end
71
+ # end
72
+ # end
73
+ #
74
+ # With valid events:
75
+ #
76
+ # vehicle = Vehicle.create # => #<Vehicle id=1 state="parked" alarm_state="active">
77
+ # vehicle.state_event = 'ignite'
78
+ # vehicle.alarm_state_event = 'disable'
79
+ #
80
+ # Vehicle.state_machines.fire_event_attributes(vehicle, :save) { true }
81
+ # vehicle.state # => "idling"
82
+ # vehicle.state_event # => nil
83
+ # vehicle.alarm_state # => "off"
84
+ # vehicle.alarm_state_event # => nil
85
+ #
86
+ # With invalid events:
87
+ #
88
+ # vehicle = Vehicle.create # => #<Vehicle id=1 state="parked" alarm_state="active">
89
+ # vehicle.state_event = 'park'
90
+ # vehicle.alarm_state_event = 'disable'
91
+ #
92
+ # Vehicle.state_machines.fire_event_attributes(vehicle, :save) { true }
93
+ # vehicle.state # => "parked"
94
+ # vehicle.state_event # => nil
95
+ # vehicle.alarm_state # => "active"
96
+ # vehicle.alarm_state_event # => nil
97
+ # vehicle.errors # => #<DataMapper::Validate::ValidationErrors:0xb7af9abc @errors={"state_event"=>["is invalid"]}>
98
+ #
99
+ # With partial firing:
100
+ #
101
+ # vehicle = Vehicle.create # => #<Vehicle id=1 state="parked" alarm_state="active">
102
+ # vehicle.state_event = 'ignite'
103
+ #
104
+ # Vehicle.state_machines.fire_event_attributes(vehicle, :save, false) { true }
105
+ # vehicle.state # => "idling"
106
+ # vehicle.state_event # => "ignite"
107
+ # vehicle.state_event_transition # => #<StateMachine::Transition attribute=:state event=:ignite from="parked" from_name=:parked to="idling" to_name=:idling>
108
+ def fire_event_attributes(object, action, complete = true)
109
+ # Get the transitions to fire for each applicable machine
110
+ transitions = map {|attribute, machine| machine.action == action ? machine.events.attribute_transition_for(object, true) : nil}.compact
111
+ return yield if transitions.empty?
112
+
113
+ # The value generated by the yielded block (the actual action)
114
+ action_value = nil
115
+
116
+ # Make sure all events were valid
117
+ if result = transitions.all? {|transition| transition != false}
118
+ begin
119
+ result = Transition.perform(transitions, :after => complete) do
120
+ # Prevent events from being evaluated multiple times if actions are nested
121
+ transitions.each {|transition| transition.machine.write(object, :event, nil)}
122
+ action_value = yield
123
+ end
124
+ rescue Exception
125
+ # Revert attribute modifications
126
+ transitions.each do |transition|
127
+ transition.machine.write(object, :event, transition.event)
128
+ transition.machine.write(object, :event_transition, nil) if complete
129
+ end
130
+
131
+ raise
132
+ end
133
+
134
+ transitions.each do |transition|
135
+ # Revert event unless transition was successful
136
+ transition.machine.write(object, :event, transition.event) unless complete && result
137
+
138
+ # Track transition if partial transition completed successfully
139
+ transition.machine.write(object, :event_transition, !complete && result ? transition : nil)
140
+ end
141
+ end
142
+
143
+ action_value.nil? ? result : action_value
144
+ end
145
+ end
146
+ 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