Skip to content
24 changes: 19 additions & 5 deletions lib/identity_cache/cached/attribute.rb
Original file line number Diff line number Diff line change
Expand Up @@ -34,19 +34,33 @@ def fetch(db_key)
end
end

def expire(record)
def expire_for_save(record)
unless record.send(:was_new_record?)
old_key = old_cache_key(record)
old_key = old_cache_key_for_record(record)
IdentityCache.cache.delete(old_key)
end
unless record.destroyed?
new_key = new_cache_key(record)
new_key = new_cache_key_for_record(record)
if new_key != old_key
IdentityCache.cache.delete(new_key)
end
end
end

def expire_for_values(values_hash)
key_values = key_fields.map { |name| values_hash.fetch(name) }
IdentityCache.cache.delete(cache_key_from_key_values(key_values))
end

def expire_for_update(old_values_hash, changes)
expire_for_values(old_values_hash)

if key_fields.any? { |name| changes.key?(name) }
key_values = key_fields.map { |name| changes.fetch(name) { old_values_hash.fetch(name) } }
IdentityCache.cache.delete(cache_key_from_key_values(key_values))
end
end

def cache_key(index_key)
values_hash = IdentityCache.memcache_hash(unhashed_values_cache_key_string(index_key))
"#{model.rails_cache_key_namespace}#{cache_key_prefix}#{values_hash}"
Expand Down Expand Up @@ -95,12 +109,12 @@ def cache_key_prefix
end
end

def new_cache_key(record)
def new_cache_key_for_record(record)
new_key_values = key_fields.map { |field| record.send(field) }
cache_key_from_key_values(new_key_values)
end

def old_cache_key(record)
def old_cache_key_for_record(record)
old_key_values = key_fields.map do |field|
field_string = field.to_s
changes = record.transaction_changed_attributes
Expand Down
1 change: 1 addition & 0 deletions lib/identity_cache/configuration_dsl.rb
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ def cache_attribute_by_alias(attribute_or_proc, alias_name:, by:, unique:)
cached_attribute = klass.new(self, attribute_or_proc, alias_name, fields, unique)
cached_attribute.build
cache_indexes.push(cached_attribute)
@cache_indexed_columns = nil
end

def ensure_base_model
Expand Down
15 changes: 11 additions & 4 deletions lib/identity_cache/parent_model_expiration.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# frozen_string_literal: true
module IdentityCache
module ParentModelExpiration # :nodoc:
# @api private
module ParentModelExpiration
extend ActiveSupport::Concern
include ArTransactionChanges

Expand Down Expand Up @@ -35,9 +36,16 @@ def lazy_hooks
end
end

module ClassMethods
def parent_expiration_entries
ParentModelExpiration.install_pending_parent_expiry_hooks(cached_model)
_parent_expiration_entries
end
end

included do
class_attribute(:parent_expiration_entries)
self.parent_expiration_entries = Hash.new { |hash, key| hash[key] = [] }
class_attribute(:_parent_expiration_entries)
self._parent_expiration_entries = Hash.new { |hash, key| hash[key] = [] }
end

def expire_parent_caches
Expand All @@ -49,7 +57,6 @@ def expire_parent_caches
end

def add_parents_to_cache_expiry_set(parents_to_expire)
ParentModelExpiration.install_pending_parent_expiry_hooks(cached_model)
self.class.parent_expiration_entries.each do |association_name, cached_associations|
parents_to_expire_on_changes(parents_to_expire, association_name, cached_associations)
end
Expand Down
2 changes: 1 addition & 1 deletion lib/identity_cache/query_api.rb
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ def was_new_record? # :nodoc:

def expire_attribute_indexes # :nodoc:
cache_indexes.each do |cached_attribute|
cached_attribute.expire(self)
cached_attribute.expire_for_save(self)
end
end
end
Expand Down
64 changes: 64 additions & 0 deletions lib/identity_cache/without_primary_index.rb
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,70 @@ module ClassMethods
def primary_cache_index_enabled
false
end

# Get only the columns whose values are needed to manually expire caches
# after updating or deleting rows without triggering after_commit callbacks.
#
# 1. Pass the returned columns into Active Record's `select` or `pluck` query
# method on the scope that will be used to modify the database in order to
# query original for these rows that will be modified.
# 2. Update or delete the rows
# 3. Use {expire_cache_for_update} or {expire_cache_for_delete} to expires the
# caches, passing in the values from the query in step 1 as the indexed_values.
#
# @return [Array<Symbol>] the array of column names
def cache_indexed_columns
@cache_indexed_columns ||= begin
check_for_unsupported_parent_expiration_entries
columns = Set.new
columns << primary_key.to_sym if primary_cache_index_enabled
cache_indexes.each do |cached_attribute|
columns.merge(cached_attribute.key_fields)
end
columns.to_a.freeze
end
end

def expire_cache_for_update(old_indexed_values, changes)
if primary_cache_index_enabled
id = old_indexed_values.fetch(primary_key.to_sym)
expire_primary_key_cache_index(id)
end
cache_indexes.each do |cached_attribute|
cached_attribute.expire_for_update(old_indexed_values, changes)
end
check_for_unsupported_parent_expiration_entries
end

def expire_cache_for_insert_or_delete(indexed_values)
if primary_cache_index_enabled
id = indexed_values.fetch(primary_key.to_sym)
expire_primary_key_cache_index(id)
end
cache_indexes.each do |cached_attribute|
cached_attribute.expire_for_values(indexed_values)
end
check_for_unsupported_parent_expiration_entries
end

alias_method :expire_cache_for_insert, :expire_cache_for_insert_or_delete

alias_method :expire_cache_for_delete, :expire_cache_for_insert_or_delete

private :expire_cache_for_insert_or_delete

private

def check_for_unsupported_parent_expiration_entries
return unless parent_expiration_entries.any?
msg = +"Unsupported manual expiration of #{name} record that is embedded in parent associations:\n"
parent_expiration_entries.each do |association_name, cached_associations|
cached_associations.each do |_parent_class, _only_on_foreign_key_change|
msg << "- #{association_name}"
end
end
raise msg
end
end
end
end
13 changes: 13 additions & 0 deletions test/parent_model_expiration_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,17 @@ def test_recursively_expire_parent_caches
fetched_name = Item.fetch(item.id).fetch_associated_records.first.fetch_deeply_associated_records.first.name
assert_equal("updated child", fetched_name)
end

def test_check_for_unsupported_parent_expiration_entries
Item.cache_has_many(:associated_records, embed: true)

Item.send(:check_for_unsupported_parent_expiration_entries)
exc = assert_raises do
AssociatedRecord.send(:check_for_unsupported_parent_expiration_entries)
end
assert_equal(
"Unsupported manual expiration of AssociatedRecord record that is embedded in parent associations:\n- item",
exc.message
)
end
end
92 changes: 92 additions & 0 deletions test/without_primary_index_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# frozen_string_literal: true
require "test_helper"

module IdentityCache
class WithoutPrimaryIndexTest < IdentityCache::TestCase
def setup
super
AssociatedRecord.cache_attribute(:name)

@parent = Item.create!(title: "bob")
@record = @parent.associated_records.create!(name: "foo")
end

def test_cache_indexed_columns_returns_the_correct_columns_for_expiration
AssociatedRecord.cache_attribute(:name, by: :item_id)
expected_result = [:id, :item_id]
assert_equal(expected_result, AssociatedRecord.cache_indexed_columns)
end

def test_expire_cache_for_update
id = 1
item_id = 1
AssociatedRecord.cache_attribute(:item_id, by: :name)

assert_queries(1) do
assert_equal(item_id, AssociatedRecord.fetch_item_id_by_name("foo"))
end

AssociatedRecord.where(id: 1).update_all(name: "bar")
old_values = {
name: "foo",
id: id,
}
new_values = {
name: "bar",
id: id,
}

AssociatedRecord.expire_cache_for_update(old_values, new_values)
assert_queries(2) do
assert_equal(item_id, AssociatedRecord.fetch_item_id_by_name("bar"))
assert_nil(AssociatedRecord.fetch_item_id_by_name("foo"))
end
end

def test_expire_cache_for_update_raises_when_a_hash_is_missing_an_index_key
expected_error_message = "key not found: :id"
old_values = {
name: "foo",
}
new_values = {
name: "bar",
}

error = assert_raises(KeyError) do
AssociatedRecord.expire_cache_for_update(old_values, new_values)
end

assert_equal(expected_error_message, error.message)
end

def test_expire_cache_for_insert
test_record_name = "Test Record"
AssociatedRecord.insert_all([{name: test_record_name}])
test_record = AssociatedRecord.find_by(name: test_record_name)
expire_hash_keys = {
id: test_record.id,
}

assert_equal(test_record_name, AssociatedRecord.fetch_name_by_id(test_record.id))
AssociatedRecord.expire_cache_for_insert(expire_hash_keys)
assert_queries(1) do
assert_equal(test_record_name, AssociatedRecord.fetch_name_by_id(test_record.id))
end
end

def test_expire_cache_for_delete
assert_equal("foo", AssociatedRecord.fetch_name_by_id(@record.id))
expire_hash_keys = {
id: @record.id,
}

AssociatedRecord.delete(@record.id)
assert_equal("foo", AssociatedRecord.fetch_name_by_id(@record.id))

AssociatedRecord.expire_cache_for_delete(expire_hash_keys)
assert_queries(1) do
assert_nil(AssociatedRecord.fetch_name_by_id(@record.id))
end
end
end
end