diff --git a/lib/identity_cache/cached/attribute.rb b/lib/identity_cache/cached/attribute.rb index 96e83e92..3e72b49c 100644 --- a/lib/identity_cache/cached/attribute.rb +++ b/lib/identity_cache/cached/attribute.rb @@ -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}" @@ -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 diff --git a/lib/identity_cache/configuration_dsl.rb b/lib/identity_cache/configuration_dsl.rb index bb5e65fc..f7450800 100644 --- a/lib/identity_cache/configuration_dsl.rb +++ b/lib/identity_cache/configuration_dsl.rb @@ -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 diff --git a/lib/identity_cache/parent_model_expiration.rb b/lib/identity_cache/parent_model_expiration.rb index 25fbb956..fcd9e0e0 100644 --- a/lib/identity_cache/parent_model_expiration.rb +++ b/lib/identity_cache/parent_model_expiration.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true module IdentityCache - module ParentModelExpiration # :nodoc: + # @api private + module ParentModelExpiration extend ActiveSupport::Concern include ArTransactionChanges @@ -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 @@ -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 diff --git a/lib/identity_cache/query_api.rb b/lib/identity_cache/query_api.rb index c64f0b44..8bd3734f 100644 --- a/lib/identity_cache/query_api.rb +++ b/lib/identity_cache/query_api.rb @@ -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 diff --git a/lib/identity_cache/without_primary_index.rb b/lib/identity_cache/without_primary_index.rb index 5b4ed22c..d01ba42e 100644 --- a/lib/identity_cache/without_primary_index.rb +++ b/lib/identity_cache/without_primary_index.rb @@ -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] 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 diff --git a/test/parent_model_expiration_test.rb b/test/parent_model_expiration_test.rb index 97cec203..0f0a722e 100644 --- a/test/parent_model_expiration_test.rb +++ b/test/parent_model_expiration_test.rb @@ -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 diff --git a/test/without_primary_index_test.rb b/test/without_primary_index_test.rb new file mode 100644 index 00000000..02c4e68f --- /dev/null +++ b/test/without_primary_index_test.rb @@ -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 \ No newline at end of file