Class: StateMachines::State

Inherits:
Object
  • Object
show all
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

Instance Method Summary collapse

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, options = nil, initial: false, value: :__not_provided__, cache: nil, if: nil, human_name: nil, **extra_options) # :nodoc:
  # Handle both old hash style and new kwargs style for backward compatibility
  if options.is_a?(Hash)
    # Old style: initialize(machine, name, {initial: true, value: 'foo'})
    StateMachines::OptionsValidator.assert_valid_keys!(options, :initial, :value, :cache, :if, :human_name)
    initial = options.fetch(:initial, false)
    value = options.include?(:value) ? options[:value] : :__not_provided__
    cache = options[:cache]
    if_condition = options[:if]
    human_name = options[: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 options.nil?

    StateMachines::OptionsValidator.assert_valid_keys!(extra_options, :initial, :value, :cache, :if, :human_name) unless extra_options.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

#cacheObject

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

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

#machineObject

The state machine for which this state is defined



16
17
18
# File 'lib/state_machines/state.rb', line 16

def machine
  @machine
end

#matcherObject

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

#nameObject (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_nameObject (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, &)
  options = args.last.is_a?(Hash) ? args.pop : {}
  options = { method_name: method }.merge(options)
  state = machine.states.match!(object)

  if state == self && object.respond_to?(method)
    object.send(method, *args, &)
  elsif (method_missing = options[: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 == options[: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

#contextObject

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_methodsObject

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(options = {})
  label = options[: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, options = {}, io = $stdout)
  machine.renderer.draw_state(self, graph, options, 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.

Returns:

  • (Boolean)


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

#inspectObject

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

Returns:

  • (Boolean)


195
196
197
# File 'lib/state_machines/state.rb', line 195

def matches?(other_value)
  matcher ? matcher.call(other_value) : other_value == value
end