joelind-state_machine 0.8.1

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 +297 -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 +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/transition.rb +394 -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 +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 +1374 -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 +163 -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