Skip to content

Commit

Permalink
Add built-in caching for dynamic state values when the value only nee…
Browse files Browse the repository at this point in the history
…ds to be generated once
  • Loading branch information
obrie committed May 25, 2009
1 parent 6d2c2ba commit b75945a
Show file tree
Hide file tree
Showing 5 changed files with 69 additions and 6 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.rdoc
@@ -1,5 +1,6 @@
== master

* Add built-in caching for dynamic state values when the value only needs to be generated once
* Fix flawed example for using record ids as state values
* Don't evaluate state values until they're actually used in an object instance
* Make it easier to use event attributes for actions defined in the same class as the state machine
Expand Down
12 changes: 8 additions & 4 deletions lib/state_machine/machine.rb
Expand Up @@ -567,6 +567,8 @@ def initial_state(object)
# Configuration options:
# * <tt>:value</tt> - The actual value to store when an object transitions
# to the state. Default is the name (stringified).
# * <tt>:cache</tt> - If a dynamic value (via a lambda block) is being used,
# then setting this to true will cache the evaluated result
# * <tt>:if</tt> - Determines whether an object's value matches the state
# (e.g. :value => lambda {Time.now}, :if => lambda {|state| !state.nil?}).
# By default, the configured value is matched.
Expand Down Expand Up @@ -620,7 +622,7 @@ def initial_state(object)
# end
#
# states.each do |state|
# self.state(state.name, :value => lambda { VehicleState.find_by_name(state.name.to_s).id })
# self.state(state.name, :value => lambda { VehicleState.find_by_name(state.name.to_s).id }, :cache => true)
# end
# end
# end
Expand All @@ -632,8 +634,9 @@ def initial_state(object)
# data (i.e. no VehicleState records available).
#
# One caveat to the above example is to keep performance in mind. To avoid
# constant db hits for looking up the VehicleState ids, an in-memory cache
# should be used like so:
# constant db hits for looking up the VehicleState ids, the value is cached
# by specifying the <tt>:cache</tt> option. Alternatively, a custom
# caching strategy can be used like so:
#
# class VehicleState < ActiveRecord::Base
# cattr_accessor :cache_store
Expand Down Expand Up @@ -803,7 +806,7 @@ def initial_state(object)
# options hash which contains at least <tt>:if</tt> condition support.
def state(*names, &block)
options = names.last.is_a?(Hash) ? names.pop : {}
assert_valid_keys(options, :value, :if)
assert_valid_keys(options, :value, :cache, :if)

states = add_states(names)
states.each do |state|
Expand All @@ -812,6 +815,7 @@ def state(*names, &block)
self.states.update(state)
end

state.cache = options[:cache] if options.include?(:cache)
state.matcher = options[:if] if options.include?(:if)
state.context(&block) if block_given?
end
Expand Down
23 changes: 21 additions & 2 deletions lib/state_machine/state.rb
Expand Up @@ -27,6 +27,9 @@ class State
# transitions into this state
attr_writer :value

# Whether this state's value should be cached after being evaluated
attr_accessor :cache

# Whether or not this state is the initial state to use for new objects
attr_accessor :initial
alias_method :initial?, :initial
Expand All @@ -48,16 +51,19 @@ class State
# machine. Default is false.
# * <tt>:value</tt> - The value to store when an object transitions to this
# state. Default is the name (stringified).
# * <tt>:cache</tt> - If a dynamic value (via a lambda block) is being used,
# then setting this to true will cache the evaluated result
# * <tt>:if</tt> - 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.
def initialize(machine, name, options = {}) #:nodoc:
assert_valid_keys(options, :initial, :value, :if)
assert_valid_keys(options, :initial, :value, :cache, :if)

@machine = machine
@name = name
@qualified_name = name && machine.namespace ? :"#{machine.namespace}_#{name}" : name
@value = options.include?(:value) ? options[:value] : name && name.to_s
@cache = options[:cache]
@matcher = options[:if]
@methods = {}
@initial = options[:initial] == true
Expand Down Expand Up @@ -111,7 +117,15 @@ def description
# 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@...>
def value(eval = true)
@value.is_a?(Proc) && eval ? @value.call : @value
if @value.is_a?(Proc) && eval
if cache_value?
instance_variable_defined?('@cached_value') ? @cached_value : @cached_value = @value.call
else
@value.call
end
else
@value
end
end

# Determines whether this state matches the given value. If no matcher is
Expand Down Expand Up @@ -214,6 +228,11 @@ def inspect
end

private
# Should the value be cached after it's evaluated for the first time?
def cache_value?
@cache
end

# Adds a predicate method to the owner class so long as a name has
# actually been configured for the state
def add_predicate
Expand Down
18 changes: 18 additions & 0 deletions test/unit/machine_test.rb
Expand Up @@ -1101,6 +1101,24 @@ def test_should_use_custom_matcher
end
end

class MachineWithCachedStateTest < Test::Unit::TestCase
def setup
@klass = Class.new
@machine = StateMachine::Machine.new(@klass, :initial => :parked)
@state = @machine.state :parked, :value => lambda {Object.new}, :cache => true

@object = @klass.new
end

def test_should_use_evaluated_value
assert_instance_of Object, @object.state
end

def test_use_same_value_across_multiple_objects
assert_equal @object.state, @klass.new.state
end
end

class MachineWithStatesWithBehaviorsTest < Test::Unit::TestCase
def setup
@klass = Class.new
Expand Down
21 changes: 21 additions & 0 deletions test/unit/state_test.rb
Expand Up @@ -254,6 +254,27 @@ def test_should_match_evaluated_value
end
end

class StateWithCachedLambdaValueTest < Test::Unit::TestCase
def setup
@klass = Class.new
@machine = StateMachine::Machine.new(@klass)
@state = StateMachine::State.new(@machine, :parked, :value => lambda {Object.new}, :cache => true)
end

def test_should_be_caching
assert @state.cache
end

def test_should_evaluate_value
assert_instance_of Object, @state.value
end

def test_should_only_evaluate_value_once
value = @state.value
assert_same value, @state.value
end
end

class StateWithMatcherTest < Test::Unit::TestCase
def setup
@klass = Class.new
Expand Down

0 comments on commit b75945a

Please sign in to comment.