state_machine 0.5.1 → 0.5.2

Sign up to get free protection for your applications and to get access to all the features.
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.1'
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
@@ -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.requirements[:to] ? guard.requirements[:to].first : from
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=[{:to => [:parked], :from => [:idling]}]>
143
+ # event # => #<StateMachine::Event name=:park transitions=[:idling => :parked]>
144
144
  def inspect
145
- attributes = [[:name, name], [:transitions, guards.map {|guard| guard.requirements}]]
146
- "#<#{self.class} #{attributes.map {|name, value| "#{name}=#{value.inspect}"} * ' '}>"
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
@@ -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 transition/condition options that must be met in order for the guard
14
- # to match
15
- attr_reader :requirements
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 requirements (in the same order):
19
- # * +from+
20
- # * +except_from+
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 with the given requirements
26
- def initialize(requirements = {}) #:nodoc:
27
- assert_valid_keys(requirements, :from, :to, :on, :except_from, :except_to, :except_on, :if, :unless)
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
- @requirements = requirements
30
- @known_states = []
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
- # Normalize the requirements and track known states. The order that
33
- # requirements are iterated is based on the priority in which tracked
34
- # states should be added (from followed by to states).
35
- [:from, :except_from, :to, :except_to, :on, :except_on].each do |option|
36
- if @requirements.include?(option)
37
- values = @requirements[option]
38
-
39
- @requirements[option] = values = [values] unless values.is_a?(Array)
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
- # Determines whether the given object / query matches the requirements
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
- # * +from+ - One or more states being transitioned from. If none are specified, then this will always match.
52
- # * +to+ - One or more states being transitioned to. If none are specified, then this will always match.
53
- # * +on+ - One or more events that fired the transition. If none are specified, then this will always match.
54
- # * +except_from+ - One or more states *not* being transitioned from
55
- # * +except_to+ - One more states *not* being transitioned to
56
- # * +except_on+ - One or more events that *did not* fire the transition.
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(:on => :ignite, :from => [nil, :parked], :to => :idling)
71
+ # guard = StateMachine::Guard.new(:from => [nil, :parked], :to => :idling, :on => :ignite)
61
72
  #
62
73
  # # Successful
63
- # guard.matches?(object, :on => :ignite) # => true
64
- # guard.matches?(object, :from => nil) # => true
65
- # guard.matches?(object, :from => :parked) # => true
66
- # guard.matches?(object, :to => :idling) # => true
67
- # guard.matches?(object, :from => :parked, :to => :idling) # => true
68
- # guard.matches?(object, :on => :ignite, :from => :parked, :to => :idling) # => true
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) # => false
72
- # guard.matches?(object, :from => :idling) # => false
73
- # guard.matches?(object, :to => :first_gear) # => false
74
- # guard.matches?(object, :from => :parked, :to => :first_gear) # => false
75
- # guard.matches?(object, :on => :park, :from => :parked, :to => :idling) # => false
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?(object, query) && matches_conditions?(object)
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
- # From states: :from, everything but :except states, or all states
101
- from_states = requirements[:from] || requirements[:except_from] && (valid_states - requirements[:except_from]) || valid_states
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
- # To state can be optional, otherwise it's a loopback
104
- to_state = requirements[:to] && requirements[:to].first
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.collect do |from_state|
108
- graph.add_edge(from_state.to_s, (to_state || from_state).to_s, :label => event.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
- # Verify that the from state, to state, and event match the query
114
- def matches_query?(object, query)
115
- !query || query.empty? || [:from, :to, :on].all? do |option|
116
- !query.include?(option) || find_match(query[option], requirements[option], requirements[:"except_#{option}"])
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
- # Verify that the conditionals for this guard evaluate to true for the
121
- # given object
122
- def matches_conditions?(object)
123
- if requirements[:if]
124
- evaluate_method(object, requirements[:if])
125
- elsif requirements[:unless]
126
- !evaluate_method(object, requirements[:unless])
127
- else
128
- true
129
- end
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
- # Attempts to find the given value in either a whitelist of values or
133
- # a blacklist of values. The whitelist will always be used first if it
134
- # is specified. If neither lists are specified, then this will always
135
- # find a match and return true.
136
- #
137
- # == Examples
138
- #
139
- # # No list
140
- # find_match(:parked, nil, nil) # => true
141
- #
142
- # # Whitelist
143
- # find_match(nil, [:parked, :idling], nil) # => false
144
- # find_match(nil, [nil], nil) # => true
145
- # find_match(:parked, [:parked, :idling], nil) # => true
146
- # find_match(:first_gear, [:parked, :idling], nil) # => false
147
- #
148
- # # Blacklist
149
- # find_match(nil, nil, [:parked, :idling]) # => true
150
- # find_match(nil, nil, [nil]) # => false
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