diff --git a/.ruby-version b/.ruby-version index 37c2961..fd2a018 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -2.7.2 +3.1.0 diff --git a/README.md b/README.md index e88405e..227b278 100644 --- a/README.md +++ b/README.md @@ -76,12 +76,17 @@ end do_caching true + preloads do |cereals, options| + ActiveRecord::Associations::Preloader.new.preload(cereals, :maker) + end + cache_key_addons { |options| options[:be_trendy] } dependency_key 'V3' serialize :uid, from: :id, to: String serialize :name, :company serialize :copyright, through: :legal_info + serialize :maker_name, through: :maker serialize :spiel do |cereal, _options| 'Made with whole grains!' if cereal.ingredients[:whole_grains] > 0.000001 end @@ -136,6 +141,7 @@ CerealSerializer.new(Cereal.first, be_trendy: true, include: :ingredients).as_js "name": "Eyeholes", "company": "Needful Things", "copyright": "© Need Things 2019", + "maker_name": "Cereal Inc", "spiel": "Made with whole grains!", "tagline": "Part of a balanced breakfast", "small_print": "This doesn't mean jack-squat", @@ -257,6 +263,15 @@ def fine_print end ``` +### Preload data to avoid n+1 issues when rendering a collection of items +```ruby +preloads do |cereals, options| + ActiveRecord::Associations::Preloader.new.preload(cereals, :maker) +end +``` + +When a serializer is cached, the `cereals` will contain only those entries for which no cache was found. + ### Include other data ```ruby class CerealSerializer < CacheCrispies::Base diff --git a/lib/cache_crispies/base.rb b/lib/cache_crispies/base.rb index 88705e5..dde0d3d 100644 --- a/lib/cache_crispies/base.rb +++ b/lib/cache_crispies/base.rb @@ -178,6 +178,15 @@ def self.file_hashes ).uniq.sort end + def self.preloads(collection = [], options = {}, &block) + if block_given? + @preloads = block + nil + else + @preloads&.call(collection, options) + end + end + private def self.file_hash diff --git a/lib/cache_crispies/collection.rb b/lib/cache_crispies/collection.rb index 395908b..95a93a2 100644 --- a/lib/cache_crispies/collection.rb +++ b/lib/cache_crispies/collection.rb @@ -34,6 +34,7 @@ def as_json attr_reader :collection, :serializer, :options def uncached_json + @serializer.preloads(collection, options) collection.map do |model| serializer.new(model, options).as_json end @@ -46,11 +47,18 @@ def cached_json hash[plan.cache_key] = model end - CacheCrispies.cache.fetch_multi(*models_by_cache_key.keys) do |cache_key| - model = models_by_cache_key[cache_key] + already_cached = CacheCrispies.cache.read_multi(*models_by_cache_key.keys) - serializer.new(model, options).as_json - end.values + missing_keys = models_by_cache_key.keys - already_cached.keys + missing_values = models_by_cache_key.fetch_values(*missing_keys) + @serializer.preloads(missing_values, options) + + new_entries = missing_keys.each_with_object({}) do |key, hash| + hash[key] = serializer.new(models_by_cache_key[key], options).as_json + end + + CacheCrispies.cache.write_multi(new_entries) + new_entries.values + already_cached.values end end end diff --git a/spec/cache_crispies/collection_spec.rb b/spec/cache_crispies/collection_spec.rb index d4a92d7..106530a 100644 --- a/spec/cache_crispies/collection_spec.rb +++ b/spec/cache_crispies/collection_spec.rb @@ -58,26 +58,55 @@ def models.cache_key() end it 'fetches the cache for each object in the collection' do expect(CacheCrispies::Plan).to receive(:new).with( - serializer, model1, options + serializer, model1, **options ).and_return double('plan-dbl-1', cache_key: 'cereal-key-1') expect(CacheCrispies::Plan).to receive(:new).with( - serializer, model2, options + serializer, model2, **options ).and_return double('plan-dbl-2', cache_key: 'cereal-key-2') + expect(CacheCrispies).to receive_message_chain(:cache, :read_multi). + with('cereal-key-1', 'cereal-key-2').and_return({}) + expect(CacheCrispies).to receive_message_chain( - :cache, :fetch_multi + :cache, :write_multi ).with( - 'cereal-key-1', 'cereal-key-2' - ).and_yield('cereal-key-1').and_return( - name: name1 - ).and_yield('cereal-key-2').and_return( - name: name2 + { 'cereal-key-1' => { name: name1 }, 'cereal-key-2' => { name: name2 } } ) subject.as_json end end + + context 'when the collection cache key does not miss' do + before do + allow(CacheCrispies).to receive_message_chain( + :cache, :fetch + ).with('cacheable-collection-key').and_yield + end + + it 'fetches the cache for each object in the collection' do + expect(CacheCrispies::Plan).to receive(:new).with( + serializer, model1, **options + ).and_return double('plan-dbl-1', cache_key: 'cereal-key-1') + + expect(CacheCrispies::Plan).to receive(:new).with( + serializer, model2, **options + ).and_return double('plan-dbl-2', cache_key: 'cereal-key-2') + + expect(CacheCrispies).to receive_message_chain(:cache, :read_multi). + with('cereal-key-1', 'cereal-key-2'). + and_return( + { 'cereal-key-1' => { name: name1 }, 'cereal-key-2' => { name: name2 } } + ) + + expect(CacheCrispies).to receive_message_chain( + :cache, :write_multi + ).with({}) + + subject.as_json + end + end end end end