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 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