state_machine 0.5.1 → 0.5.2
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.
- data/CHANGELOG.rdoc +7 -0
- data/Rakefile +1 -1
- data/lib/state_machine/assertions.rb +16 -0
- data/lib/state_machine/event.rb +7 -4
- data/lib/state_machine/guard.rb +113 -90
- data/lib/state_machine/machine.rb +1 -1
- data/lib/state_machine/matcher.rb +123 -0
- data/lib/state_machine/state.rb +3 -2
- data/test/active_record.log +3480 -20
- data/test/sequel.log +1071 -1
- data/test/unit/assertions_test.rb +27 -1
- data/test/unit/callback_test.rb +4 -3
- data/test/unit/event_test.rb +1 -1
- data/test/unit/guard_test.rb +64 -36
- data/test/unit/integrations/active_record_test.rb +15 -0
- data/test/unit/integrations/data_mapper_test.rb +15 -0
- data/test/unit/integrations/sequel_test.rb +15 -0
- data/test/unit/matcher_test.rb +155 -0
- data/test/unit/state_test.rb +1 -1
- metadata +5 -2
data/CHANGELOG.rdoc
CHANGED
@@ -1,5 +1,12 @@
|
|
1
1
|
== master
|
2
2
|
|
3
|
+
== 0.5.2 / 2009-02-17
|
4
|
+
|
5
|
+
* Improve pretty-print of events
|
6
|
+
* Simplify state/event matching design, improving guard performance by 30%
|
7
|
+
* Add better error notification when conflicting guard options are defined
|
8
|
+
* Fix scope name pluralization not being applied correctly
|
9
|
+
|
3
10
|
== 0.5.1 / 2009-02-11
|
4
11
|
|
5
12
|
* Allow states to be drawn as ellipses to accommodate long names
|
data/Rakefile
CHANGED
@@ -5,7 +5,7 @@ require 'rake/contrib/sshpublisher'
|
|
5
5
|
|
6
6
|
spec = Gem::Specification.new do |s|
|
7
7
|
s.name = 'state_machine'
|
8
|
-
s.version = '0.5.
|
8
|
+
s.version = '0.5.2'
|
9
9
|
s.platform = Gem::Platform::RUBY
|
10
10
|
s.summary = 'Adds support for creating state machines for attributes on any Ruby class'
|
11
11
|
|
@@ -17,5 +17,21 @@ module StateMachine
|
|
17
17
|
invalid_keys = hash.keys - valid_keys
|
18
18
|
raise ArgumentError, "Invalid key(s): #{invalid_keys.join(', ')}" unless invalid_keys.empty?
|
19
19
|
end
|
20
|
+
|
21
|
+
# Validates that at *most* one of a set of exclusive keys is included in
|
22
|
+
# the given hash. If more than one key is found, an ArgumentError will be
|
23
|
+
# raised.
|
24
|
+
#
|
25
|
+
# == Examples
|
26
|
+
#
|
27
|
+
# options = {:only => :on, :except => :off}
|
28
|
+
# assert_exclusive_keys(options, :only) # => nil
|
29
|
+
# assert_exclusive_keys(options, :except) # => nil
|
30
|
+
# assert_exclusive_keys(options, :only, :except) # => ArgumentError: Conflicting keys: only, except
|
31
|
+
# assert_exclusive_keys(options, :only, :except, :with) # => ArgumentError: Conflicting keys: only, except
|
32
|
+
def assert_exclusive_keys(hash, *exclusive_keys)
|
33
|
+
conflicting_keys = exclusive_keys & hash.keys
|
34
|
+
raise ArgumentError, "Conflicting keys: #{conflicting_keys.join(', ')}" unless conflicting_keys.length <= 1
|
35
|
+
end
|
20
36
|
end
|
21
37
|
end
|
data/lib/state_machine/event.rb
CHANGED
@@ -98,7 +98,7 @@ module StateMachine
|
|
98
98
|
|
99
99
|
if guard = guards.find {|guard| guard.matches?(object, :from => from)}
|
100
100
|
# Guard allows for the transition to occur
|
101
|
-
to = guard.
|
101
|
+
to = guard.state_requirement[:to].values.any? ? guard.state_requirement[:to].values.first : from
|
102
102
|
Transition.new(object, machine, name, from, to)
|
103
103
|
end
|
104
104
|
end
|
@@ -140,10 +140,13 @@ module StateMachine
|
|
140
140
|
#
|
141
141
|
# event = StateMachine::Event.new(machine, :park)
|
142
142
|
# event.transition :to => :parked, :from => :idling
|
143
|
-
# event # => #<StateMachine::Event name=:park transitions=[
|
143
|
+
# event # => #<StateMachine::Event name=:park transitions=[:idling => :parked]>
|
144
144
|
def inspect
|
145
|
-
|
146
|
-
|
145
|
+
transitions = guards.map do |guard|
|
146
|
+
"#{guard.state_requirement[:from].description} => #{guard.state_requirement[:to].description}"
|
147
|
+
end
|
148
|
+
|
149
|
+
"#<#{self.class} name=#{name.inspect} transitions=[#{transitions * ', '}]>"
|
147
150
|
end
|
148
151
|
|
149
152
|
protected
|
data/lib/state_machine/guard.rb
CHANGED
@@ -1,3 +1,4 @@
|
|
1
|
+
require 'state_machine/matcher'
|
1
2
|
require 'state_machine/eval_helpers'
|
2
3
|
require 'state_machine/assertions'
|
3
4
|
|
@@ -10,71 +11,81 @@ module StateMachine
|
|
10
11
|
include Assertions
|
11
12
|
include EvalHelpers
|
12
13
|
|
13
|
-
# The
|
14
|
-
|
15
|
-
|
14
|
+
# The condition that must be met on an object
|
15
|
+
attr_reader :if_condition
|
16
|
+
|
17
|
+
# The condition that must *not* be met on an object
|
18
|
+
attr_reader :unless_condition
|
19
|
+
|
20
|
+
# The requirement for verifying the event being guarded (includes :on and
|
21
|
+
# :except_on).
|
22
|
+
attr_reader :event_requirement
|
23
|
+
|
24
|
+
# The requirement for verifying the states being guarded (includes :from,
|
25
|
+
# :to, :except_from, and :except_to). All options map to
|
26
|
+
# either nil (if not specified) or an array of state names.
|
27
|
+
attr_reader :state_requirement
|
16
28
|
|
17
29
|
# A list of all of the states known to this guard. This will pull states
|
18
|
-
# from the following
|
19
|
-
# * +from+
|
20
|
-
# * +
|
21
|
-
# * +to+
|
22
|
-
# * +except_to+
|
30
|
+
# from the following options in +state_requirements+ (in the same order):
|
31
|
+
# * +from+ / +except_from+
|
32
|
+
# * +to+ / +except_to+
|
23
33
|
attr_reader :known_states
|
24
34
|
|
25
|
-
# Creates a new guard
|
26
|
-
def initialize(
|
27
|
-
assert_valid_keys(
|
35
|
+
# Creates a new guard
|
36
|
+
def initialize(options = {}) #:nodoc:
|
37
|
+
assert_valid_keys(options, :from, :to, :on, :except_from, :except_to, :except_on, :if, :unless)
|
28
38
|
|
29
|
-
|
30
|
-
|
39
|
+
# Build conditionals
|
40
|
+
assert_exclusive_keys(options, :if, :unless)
|
41
|
+
@if_condition = options.delete(:if)
|
42
|
+
@unless_condition = options.delete(:unless)
|
31
43
|
|
32
|
-
#
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
@known_states |= values if [:from, :to, :except_from, :except_to].include?(option)
|
41
|
-
end
|
42
|
-
end
|
44
|
+
# Build event/state requirements
|
45
|
+
@event_requirement = build_matcher(options, :on, :except_on)
|
46
|
+
@state_requirement = {:from => build_matcher(options, :from, :except_from), :to => build_matcher(options, :to, :except_to)}
|
47
|
+
|
48
|
+
# Track known states. The order that requirements are iterated is based
|
49
|
+
# on the priority in which tracked states should be added.
|
50
|
+
@known_states = []
|
51
|
+
[:from, :to].each {|option| @known_states |= @state_requirement[option].values}
|
43
52
|
end
|
44
53
|
|
45
|
-
#
|
54
|
+
# Attempts to match the given object / query against the set of requirements
|
46
55
|
# configured for this guard. In addition to matching the event, from state,
|
47
56
|
# and to state, this will also check whether the configured :if/:unless
|
48
57
|
# conditions pass on the given object.
|
49
58
|
#
|
59
|
+
# This will return true or false depending on whether a match is found.
|
60
|
+
#
|
50
61
|
# Query options:
|
51
|
-
# *
|
52
|
-
#
|
53
|
-
# *
|
54
|
-
#
|
55
|
-
# *
|
56
|
-
#
|
62
|
+
# * <tt>:from</tt> - One or more states being transitioned from. If none
|
63
|
+
# are specified, then this will always match.
|
64
|
+
# * <tt>:to</tt> - One or more states being transitioned to. If none are
|
65
|
+
# specified, then this will always match.
|
66
|
+
# * <tt>:on</tt> - One or more events that fired the transition. If none
|
67
|
+
# are specified, then this will always match.
|
57
68
|
#
|
58
69
|
# == Examples
|
59
70
|
#
|
60
|
-
# guard = StateMachine::Guard.new(:
|
71
|
+
# guard = StateMachine::Guard.new(:from => [nil, :parked], :to => :idling, :on => :ignite)
|
61
72
|
#
|
62
73
|
# # Successful
|
63
|
-
# guard.matches?(object, :on => :ignite)
|
64
|
-
# guard.matches?(object, :from => nil)
|
65
|
-
# guard.matches?(object, :from => :parked)
|
66
|
-
# guard.matches?(object, :to => :idling)
|
67
|
-
# guard.matches?(object, :from => :parked, :to => :idling)
|
68
|
-
# guard.matches?(object, :on => :ignite, :from => :parked, :to => :idling)
|
74
|
+
# guard.matches?(object, :on => :ignite) # => true
|
75
|
+
# guard.matches?(object, :from => nil) # => true
|
76
|
+
# guard.matches?(object, :from => :parked) # => true
|
77
|
+
# guard.matches?(object, :to => :idling) # => true
|
78
|
+
# guard.matches?(object, :from => :parked, :to => :idling) # => true
|
79
|
+
# guard.matches?(object, :on => :ignite, :from => :parked, :to => :idling) # => true
|
69
80
|
#
|
70
81
|
# # Unsuccessful
|
71
|
-
# guard.matches?(object, :on => :park)
|
72
|
-
# guard.matches?(object, :from => :idling)
|
73
|
-
# guard.matches?(object, :to => :first_gear)
|
74
|
-
# guard.matches?(object, :from => :parked, :to => :first_gear)
|
75
|
-
# guard.matches?(object, :on => :park, :from => :parked, :to => :idling)
|
82
|
+
# guard.matches?(object, :on => :park) # => false
|
83
|
+
# guard.matches?(object, :from => :idling) # => false
|
84
|
+
# guard.matches?(object, :to => :first_gear) # => false
|
85
|
+
# guard.matches?(object, :from => :parked, :to => :first_gear) # => false
|
86
|
+
# guard.matches?(object, :on => :park, :from => :parked, :to => :idling) # => false
|
76
87
|
def matches?(object, query = {})
|
77
|
-
matches_query?(
|
88
|
+
matches_query?(query) && matches_conditions?(object)
|
78
89
|
end
|
79
90
|
|
80
91
|
# Draws a representation of this guard on the given graph. This will draw
|
@@ -97,64 +108,76 @@ module StateMachine
|
|
97
108
|
#
|
98
109
|
# The collection of edges generated on the graph will be returned.
|
99
110
|
def draw(graph, event, valid_states)
|
100
|
-
|
101
|
-
|
111
|
+
edges = []
|
112
|
+
|
113
|
+
# From states determined based on the known valid states
|
114
|
+
from_states = state_requirement[:from].filter(valid_states)
|
102
115
|
|
103
|
-
#
|
104
|
-
|
116
|
+
# If a to state is not specified, then it's a loopback and each from
|
117
|
+
# state maps back to itself
|
118
|
+
if state_requirement[:to].values.any?
|
119
|
+
to_state = state_requirement[:to].values.first
|
120
|
+
loopback = false
|
121
|
+
else
|
122
|
+
loopback = true
|
123
|
+
end
|
105
124
|
|
106
125
|
# Generate an edge between each from and to state
|
107
|
-
from_states.
|
108
|
-
graph.add_edge(from_state.to_s, (
|
126
|
+
from_states.each do |from_state|
|
127
|
+
edges << graph.add_edge(from_state.to_s, (loopback ? from_state : to_state).to_s, :label => event.to_s)
|
109
128
|
end
|
129
|
+
|
130
|
+
edges
|
110
131
|
end
|
111
132
|
|
112
133
|
protected
|
113
|
-
#
|
114
|
-
|
115
|
-
|
116
|
-
|
134
|
+
# Builds a matcher strategy to use for the given options. If neither a
|
135
|
+
# whitelist nor a blacklist option is specified, then an AllMatcher is
|
136
|
+
# built.
|
137
|
+
def build_matcher(options, whitelist_option, blacklist_option)
|
138
|
+
assert_exclusive_keys(options, whitelist_option, blacklist_option)
|
139
|
+
|
140
|
+
if options.include?(whitelist_option)
|
141
|
+
WhitelistMatcher.new(options[whitelist_option])
|
142
|
+
elsif options.include?(blacklist_option)
|
143
|
+
BlacklistMatcher.new(options[blacklist_option])
|
144
|
+
else
|
145
|
+
AllMatcher.instance
|
117
146
|
end
|
118
147
|
end
|
119
148
|
|
120
|
-
#
|
121
|
-
# given
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
|
149
|
+
# Verifies that all configured requirements (event and state) match the
|
150
|
+
# given query. If a match is return, then a hash containing the
|
151
|
+
# event/state requirements that passed will be returned; otherwise, nil.
|
152
|
+
def matches_query?(query)
|
153
|
+
query ||= {}
|
154
|
+
matches_event?(query) && matches_states?(query)
|
155
|
+
end
|
156
|
+
|
157
|
+
# Verifies that the event requirement matches the given query
|
158
|
+
def matches_event?(query)
|
159
|
+
matches_requirement?(query, :on, event_requirement)
|
130
160
|
end
|
131
161
|
|
132
|
-
#
|
133
|
-
#
|
134
|
-
|
135
|
-
|
136
|
-
|
137
|
-
|
138
|
-
#
|
139
|
-
#
|
140
|
-
|
141
|
-
|
142
|
-
|
143
|
-
|
144
|
-
#
|
145
|
-
#
|
146
|
-
|
147
|
-
|
148
|
-
|
149
|
-
|
150
|
-
|
151
|
-
# find_match(:parked, nil, [:parked, idling]) # => false
|
152
|
-
# find_match(:first_gear, nil, [:parked, :idling]) # => true
|
153
|
-
def find_match(value, whitelist, blacklist)
|
154
|
-
if whitelist
|
155
|
-
whitelist.include?(value)
|
156
|
-
elsif blacklist
|
157
|
-
!blacklist.include?(value)
|
162
|
+
# Verifies that the state requirements match the given query. If a
|
163
|
+
# matching requirement is found, then it is returned.
|
164
|
+
def matches_states?(query)
|
165
|
+
[:from, :to].all? {|option| matches_requirement?(query, option, state_requirement[option])}
|
166
|
+
end
|
167
|
+
|
168
|
+
# Verifies that an option in the given query matches the values required
|
169
|
+
# for that option
|
170
|
+
def matches_requirement?(query, option, requirement)
|
171
|
+
!query.include?(option) || requirement.matches?(query[option], query)
|
172
|
+
end
|
173
|
+
|
174
|
+
# Verifies that the conditionals for this guard evaluate to true for the
|
175
|
+
# given object
|
176
|
+
def matches_conditions?(object)
|
177
|
+
if if_condition
|
178
|
+
evaluate_method(object, if_condition)
|
179
|
+
elsif unless_condition
|
180
|
+
!evaluate_method(object, unless_condition)
|
158
181
|
else
|
159
182
|
true
|
160
183
|
end
|
@@ -1016,7 +1016,7 @@ module StateMachine
|
|
1016
1016
|
# name or adding an "s" to the end of the name.
|
1017
1017
|
def define_scopes(custom_plural = nil)
|
1018
1018
|
attribute = self.attribute
|
1019
|
-
plural = custom_plural || (attribute.respond_to?(:pluralize) ? attribute.pluralize : "#{attribute}s")
|
1019
|
+
plural = custom_plural || (attribute.to_s.respond_to?(:pluralize) ? attribute.to_s.pluralize : "#{attribute}s")
|
1020
1020
|
|
1021
1021
|
[attribute, plural].uniq.each do |name|
|
1022
1022
|
[:with, :without].each do |kind|
|
@@ -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.new - [: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.new
|
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
|