diff --git a/lib/enumerize/attribute.rb b/lib/enumerize/attribute.rb index b22bb7c..707fe55 100644 --- a/lib/enumerize/attribute.rb +++ b/lib/enumerize/attribute.rb @@ -27,6 +27,11 @@ def initialize(klass, name, options={}) end @skip_validations_value = options.fetch(:skip_validations, false) + + # Lazily populated cache of i18n lookup keys, keyed by value name. Values + # whose #text is never rendered never build (or retain) their keys, while + # rendered values build them once and reuse them on subsequent calls. + @i18n_keys_cache = {} end def find_default_value(value) @@ -63,6 +68,26 @@ def i18n_scopes end end + # Returns the cached i18n lookup keys for +value+, building them via the + # given block on a miss. Memoizing here (rather than on the frozen Value) + # means a value's keys are composed at most once, recovering the cost of + # rebuilding them on every #text call, while values whose #text is never + # rendered never build or retain any keys. + # + # The cache is updated copy-on-write: readers always see a fully built hash + # and never a half-mutated one, so concurrent #text calls stay safe without + # locking. A race between two builds is last-writer-wins — the result is + # always correct; at worst a clobbered entry is rebuilt on its next call. + def i18n_keys(value) + key = value.to_s + cache = @i18n_keys_cache + cache[key] || begin + keys = yield + @i18n_keys_cache = cache.merge(key => keys) + keys + end + end + def options(options = {}) values = if options.empty? @values diff --git a/lib/enumerize/value.rb b/lib/enumerize/value.rb index a9672fc..0ff6eb4 100644 --- a/lib/enumerize/value.rb +++ b/lib/enumerize/value.rb @@ -14,20 +14,11 @@ def initialize(attr, name, value=nil) @value = value.nil? ? name.to_s : value super(name.to_s) - - @i18n_keys = @attr.i18n_scopes.map do |s| - scope = Utils.call_if_callable(s, @value) - - :"#{scope}.#{self}" - end - @i18n_keys << :"enumerize.defaults.#{@attr.name}.#{self}" - @i18n_keys << :"enumerize.#{@attr.name}.#{self}" - @i18n_keys << ActiveSupport::Inflector.humanize(ActiveSupport::Inflector.underscore(self)) # humanize value if there are no translations - @i18n_keys end def text - I18n.t(@i18n_keys[0], :default => @i18n_keys[1..-1]) if @i18n_keys + keys = @attr.i18n_keys(self) { build_i18n_keys } + I18n.t(keys[0], :default => keys[1..-1]) end def ==(other) @@ -44,6 +35,21 @@ def as_json(*) private + # Composes the ordered i18n lookup keys for this value. Invoked by the + # attribute only on a cache miss (see Attribute#i18n_keys), so values whose + # +text+ is never rendered never build or retain their keys. + def build_i18n_keys + keys = @attr.i18n_scopes.map do |s| + scope = Utils.call_if_callable(s, @value) + + :"#{scope}.#{self}" + end + keys << :"enumerize.defaults.#{@attr.name}.#{self}" + keys << :"enumerize.#{@attr.name}.#{self}" + keys << ActiveSupport::Inflector.humanize(ActiveSupport::Inflector.underscore(self)) # humanize value if there are no translations + keys + end + def predicate_call(value) value == self end diff --git a/test/attribute_test.rb b/test/attribute_test.rb index 6664442..8e782a1 100644 --- a/test/attribute_test.rb +++ b/test/attribute_test.rb @@ -39,6 +39,29 @@ def build_attr(*args, &block) end end + describe 'i18n keys' do + it 'builds the keys via the block on a miss' do + build_attr nil, 'foo', :in => %w[a b] + keys = attr.i18n_keys(attr.find_value('a')) { [:built] } + expect(keys).must_equal [:built] + end + + it 'memoizes per value and does not rebuild on a hit' do + build_attr nil, 'foo', :in => %w[a b] + first = attr.i18n_keys(attr.find_value('a')) { [:built] } + second = attr.i18n_keys(attr.find_value('a')) { raise 'should not rebuild' } + expect(second).must_be_same_as first + end + + it 'caches each value independently' do + build_attr nil, 'foo', :in => %w[a b] + a_keys = attr.i18n_keys(attr.find_value('a')) { [:a] } + b_keys = attr.i18n_keys(attr.find_value('b')) { [:b] } + expect(a_keys).must_equal [:a] + expect(b_keys).must_equal [:b] + end + end + describe 'arguments' do it 'returns arguments' do build_attr nil, :foo, :in => [:a, :b], :scope => true diff --git a/test/value_test.rb b/test/value_test.rb index 467a3e5..a9b36ea 100644 --- a/test/value_test.rb +++ b/test/value_test.rb @@ -11,6 +11,11 @@ class Attr < Struct.new(:values, :name, :i18n_scopes, :klass) def value?(value) values.include?(value) end + + # Caching is the real Attribute's concern; the double just composes. + def i18n_keys(_value) + yield + end end let(:attr) { Attr.new([], "attribute_name", [], Model) }