Class: StateMachines::State
- Inherits:
-
Object
- Object
- StateMachines::State
- Defined in:
- lib/state_machines/state.rb
Overview
A state defines a value that an attribute can be in after being transitioned 0 or more times. States can represent a value of any type in Ruby, though the most common (and default) type is String.
In addition to defining the machine’s value, a state can also define a behavioral context for an object when that object is in the state. See StateMachines::Machine#state for more information about how state-driven behavior can be utilized.
Instance Attribute Summary collapse
-
#cache ⇒ Object
Whether this state’s value should be cached after being evaluated.
-
#human_name(klass = @machine.owner_class) ⇒ Object
Transforms the state name into a more human-readable format, such as “first gear” instead of “first_gear”.
-
#initial ⇒ Object
(also: #initial?)
Whether or not this state is the initial state to use for new objects.
-
#machine ⇒ Object
The state machine for which this state is defined.
-
#matcher ⇒ Object
A custom lambda block for determining whether a given value matches this state.
-
#name ⇒ Object
readonly
The unique identifier for the state used in event and callback definitions.
-
#qualified_name ⇒ Object
readonly
The fully-qualified identifier for the state, scoped by the machine’s namespace.
-
#value(eval = true) ⇒ Object
The value that represents this state.
Instance Method Summary collapse
-
#call(object, method, *args) ⇒ Object
Calls a method defined in this state’s context on the given object.
-
#context ⇒ Object
Defines a context for the state which will be enabled on instances of the owner class when the machine is in this state.
-
#context_methods ⇒ Object
The list of methods that have been defined in this state’s context.
-
#description(options = {}) ⇒ Object
Generates a human-readable description of this state’s name / value:.
- #draw(graph, options = {}, io = $stdout) ⇒ Object
-
#final? ⇒ Boolean
Determines whether there are any states that can be transitioned to from this state.
-
#initialize(machine, name, options = nil, initial: false, value: :__not_provided__, cache: nil, if: nil, human_name: nil, **extra_options) ⇒ State
constructor
Creates a new state within the context of the given machine.
-
#initialize_copy(orig) ⇒ Object
Creates a copy of this state, excluding the context to prevent conflicts across different machines.
-
#inspect ⇒ Object
Generates a nicely formatted description of this state’s contents.
-
#matches?(other_value) ⇒ Boolean
Determines whether this state matches the given value.
Constructor Details
#initialize(machine, name, options = nil, initial: false, value: :__not_provided__, cache: nil, if: nil, human_name: nil, **extra_options) ⇒ State
Creates a new state within the context of the given machine.
Configuration options:
-
:initial
- Whether this state is the beginning state for the machine. Default is false. -
:value
- The value to store when an object transitions to this state. Default is the name (stringified). -
:cache
- If a dynamic value (via a lambda block) is being used, then setting this to true will cache the evaluated result -
:if
- Determines whether a value matches this state (e.g. :value => lambda Time.now, :if => lambda {|state| !state.nil?}). By default, the configured value is matched. -
:human_name
- The human-readable version of this state’s name
56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 |
# File 'lib/state_machines/state.rb', line 56 def initialize(machine, name, = nil, initial: false, value: :__not_provided__, cache: nil, if: nil, human_name: nil, **) # :nodoc: # Handle both old hash style and new kwargs style for backward compatibility if .is_a?(Hash) # Old style: initialize(machine, name, {initial: true, value: 'foo'}) StateMachines::OptionsValidator.assert_valid_keys!(, :initial, :value, :cache, :if, :human_name) initial = .fetch(:initial, false) value = .include?(:value) ? [:value] : :__not_provided__ cache = [:cache] if_condition = [:if] human_name = [:human_name] else # New style: initialize(machine, name, initial: true, value: 'foo') # options parameter should be nil in this case raise ArgumentError, "Unexpected positional argument: #{options.inspect}" unless .nil? StateMachines::OptionsValidator.assert_valid_keys!(, :initial, :value, :cache, :if, :human_name) unless .empty? if_condition = binding.local_variable_get(:if) # 'if' is a keyword, need special handling end @machine = machine @name = name @qualified_name = name && machine.namespace ? :"#{machine.namespace}_#{name}" : name @human_name = human_name || (@name ? @name.to_s.tr('_', ' ') : 'nil') @value = value == :__not_provided__ ? name&.to_s : value @cache = cache @matcher = if_condition @initial = initial == true @context = StateContext.new(self) return unless name conflicting_machines = machine.owner_class.state_machines.select do |_other_name, other_machine| other_machine != machine && other_machine.states[qualified_name, :qualified_name] end # Output a warning if another machine has a conflicting qualified name # for a different attribute if (conflict = conflicting_machines.detect do |_other_name, other_machine| other_machine.attribute != machine.attribute end) _name, other_machine = conflict warn "State #{qualified_name.inspect} for #{machine.name.inspect} is already defined in #{other_machine.name.inspect}" elsif conflicting_machines.empty? # Only bother adding predicates when another machine for the same # attribute hasn't already done so add_predicate end end |
Instance Attribute Details
#cache ⇒ Object
Whether this state’s value should be cached after being evaluated
33 34 35 |
# File 'lib/state_machines/state.rb', line 33 def cache @cache end |
#human_name(klass = @machine.owner_class) ⇒ Object
Transforms the state name into a more human-readable format, such as “first gear” instead of “first_gear”
133 134 135 |
# File 'lib/state_machines/state.rb', line 133 def human_name(klass = @machine.owner_class) @human_name.is_a?(Proc) ? @human_name.call(self, klass) : @human_name end |
#initial ⇒ Object Also known as: initial?
Whether or not this state is the initial state to use for new objects
36 37 38 |
# File 'lib/state_machines/state.rb', line 36 def initial @initial end |
#machine ⇒ Object
The state machine for which this state is defined
16 17 18 |
# File 'lib/state_machines/state.rb', line 16 def machine @machine end |
#matcher ⇒ Object
A custom lambda block for determining whether a given value matches this state
41 42 43 |
# File 'lib/state_machines/state.rb', line 41 def matcher @matcher end |
#name ⇒ Object (readonly)
The unique identifier for the state used in event and callback definitions
19 20 21 |
# File 'lib/state_machines/state.rb', line 19 def name @name end |
#qualified_name ⇒ Object (readonly)
The fully-qualified identifier for the state, scoped by the machine’s namespace
23 24 25 |
# File 'lib/state_machines/state.rb', line 23 def qualified_name @qualified_name end |
#value(eval = true) ⇒ Object
The value that represents this state. This will optionally evaluate the original block if it’s a lambda block. Otherwise, the static value is returned.
For example,
State.new(machine, :parked, :value => 1).value # => 1
State.new(machine, :parked, :value => lambda {Time.now}).value # => Tue Jan 01 00:00:00 UTC 2008
State.new(machine, :parked, :value => lambda {Time.now}).value(false) # => <Proc:0xb6ea7ca0@...>
166 167 168 169 170 171 172 173 174 175 176 177 178 |
# File 'lib/state_machines/state.rb', line 166 def value(eval = true) if @value.is_a?(Proc) && eval if cache_value? @value = @value.call machine.states.update(self) @value else @value.call end else @value end end |
Instance Method Details
#call(object, method, *args) ⇒ Object
Calls a method defined in this state’s context on the given object. All arguments and any block will be passed into the method defined.
If the method has never been defined for this state, then a NoMethodError will be raised.
242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 |
# File 'lib/state_machines/state.rb', line 242 def call(object, method, *args, &) = args.last.is_a?(Hash) ? args.pop : {} = { method_name: method }.merge() state = machine.states.match!(object) if state == self && object.respond_to?(method) object.send(method, *args, &) elsif (method_missing = [:method_missing]) # Dispatch to the superclass since the object either isn't in this state # or this state doesn't handle the method begin method_missing.call rescue NoMethodError => e raise unless e.name.to_s == [:method_name].to_s && e.args == args # No valid context for this method raise InvalidContext.new(object, "State #{state.name.inspect} for #{machine.name.inspect} is not a valid context for calling ##{options[:method_name]}") end end end |
#context ⇒ Object
Defines a context for the state which will be enabled on instances of the owner class when the machine is in this state.
This can be called multiple times. Each time a new context is created, a new module will be included in the owner class.
204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 |
# File 'lib/state_machines/state.rb', line 204 def context(&) # Include the context context = @context machine.owner_class.class_eval { include context } # Evaluate the method definitions and track which ones were added old_methods = context_methods context.class_eval(&) new_methods = context_methods.to_a.reject { |(name, method)| old_methods[name] == method } # Alias new methods so that the only execute when the object is in this state new_methods.each do |(method_name, _method)| context_name = context_name_for(method_name) context.class_eval " alias_method :\"\#{context_name}\", :\#{method_name}\n def \#{method_name}(*args, &block)\n state = self.class.state_machine(\#{machine.name.inspect}).states.fetch(\#{name.inspect})\n options = {:method_missing => lambda {super(*args, &block)}, :method_name => \#{method_name.inspect}}\n state.call(self, :\"\#{context_name}\", *(args + [options]), &block)\n end\n END_EVAL\n end\n\n true\nend\n", __FILE__, __LINE__ + 1 |
#context_methods ⇒ Object
The list of methods that have been defined in this state’s context
231 232 233 234 235 |
# File 'lib/state_machines/state.rb', line 231 def context_methods @context.instance_methods.inject({}) do |methods, name| methods.merge(name.to_sym => @context.instance_method(name)) end end |
#description(options = {}) ⇒ Object
Generates a human-readable description of this state’s name / value:
For example,
State.new(machine, :parked).description # => "parked"
State.new(machine, :parked, :value => :parked).description # => "parked"
State.new(machine, :parked, :value => nil).description # => "parked (nil)"
State.new(machine, :parked, :value => 1).description # => "parked (1)"
State.new(machine, :parked, :value => lambda {Time.now}).description # => "parked (*)
Configuration options:
-
:human_name
- Whether to use this state’s human name in the description or just the internal name
150 151 152 153 154 155 |
# File 'lib/state_machines/state.rb', line 150 def description( = {}) label = [:human_name] ? human_name : name description = +(label ? label.to_s : label.inspect) description << " (#{@value.is_a?(Proc) ? '*' : @value.inspect})" unless name.to_s == @value.to_s description end |
#draw(graph, options = {}, io = $stdout) ⇒ Object
264 265 266 |
# File 'lib/state_machines/state.rb', line 264 def draw(graph, = {}, io = $stdout) machine.renderer.draw_state(self, graph, , io) end |
#final? ⇒ Boolean
Determines whether there are any states that can be transitioned to from this state. If there are none, then this state is considered final. Any objects in a final state will remain so forever given the current machine’s definition.
121 122 123 124 125 126 127 128 129 |
# File 'lib/state_machines/state.rb', line 121 def final? machine.events.none? do |event| event.branches.any? do |branch| branch.state_requirements.any? do |requirement| requirement[:from].matches?(name) && !requirement[:to].matches?(name, from: name) end end end end |
#initialize_copy(orig) ⇒ Object
Creates a copy of this state, excluding the context to prevent conflicts across different machines.
107 108 109 110 |
# File 'lib/state_machines/state.rb', line 107 def initialize_copy(orig) # :nodoc: super @context = StateContext.new(self) end |
#inspect ⇒ Object
Generates a nicely formatted description of this state’s contents.
For example,
state = StateMachines::State.new(machine, :parked, :value => 1, :initial => true)
state # => #<StateMachines::State name=:parked value=1 initial=true context=[]>
274 275 276 277 |
# File 'lib/state_machines/state.rb', line 274 def inspect attributes = [[:name, name], [:value, @value], [:initial, initial?]] "#<#{self.class} #{attributes.map { |attr, value| "#{attr}=#{value.inspect}" } * ' '}>" end |
#matches?(other_value) ⇒ Boolean
Determines whether this state matches the given value. If no matcher is configured, then this will check whether the values are equivalent. Otherwise, the matcher will determine the result.
For example,
# Without a matcher
state = State.new(machine, :parked, :value => 1)
state.matches?(1) # => true
state.matches?(2) # => false
# With a matcher
state = State.new(machine, :parked, :value => lambda {Time.now}, :if => lambda {|value| !value.nil?})
state.matches?(nil) # => false
state.matches?(Time.now) # => true
195 196 197 |
# File 'lib/state_machines/state.rb', line 195 def matches?(other_value) matcher ? matcher.call(other_value) : other_value == value end |