From 11644fd0ceb99f3f0529323df5ad625c596b3f21 Mon Sep 17 00:00:00 2001 From: Kasper Timm Hansen Date: Sun, 15 Feb 2015 22:39:04 +0100 Subject: [PATCH] Collections automatically cache and fetch partials. Collections can take advantage of `multi_read` if they render one template and their partials begin with a cache call. The cache call must correspond to either what the collections elements are rendered as, or match the inferred name of the partial. So with a notifications/_notification.html.erb template like: ```ruby <% cache notification %> <%# ... %> <% end %> ``` A collection would be able to use `multi_read` if rendered like: ```ruby <%= render @notifications %> <%= render partial: 'notifications/notification', collection: @notifications, as: :notification %> ``` --- actionpack/test/controller/caching_test.rb | 58 +++++++++++++++++++ .../fixtures/collection_cache/index.html.erb | 1 + .../customers/_commented_customer.html.erb | 4 ++ .../fixtures/customers/_customer.html.erb | 3 + .../lib/action_view/helpers/cache_helper.rb | 23 ++++++++ .../partial_renderer/collection_caching.rb | 17 +++++- actionview/lib/action_view/template.rb | 14 +++++ .../lib/action_view/template/handlers/erb.rb | 18 ++++++ .../test/fixtures/test/_cached_customer.erb | 3 + .../fixtures/test/_cached_customer_as.erb | 3 + actionview/test/template/render_test.rb | 26 ++++++++- 11 files changed, 167 insertions(+), 3 deletions(-) create mode 100644 actionpack/test/fixtures/collection_cache/index.html.erb create mode 100644 actionpack/test/fixtures/customers/_commented_customer.html.erb create mode 100644 actionpack/test/fixtures/customers/_customer.html.erb create mode 100644 actionview/test/fixtures/test/_cached_customer.erb create mode 100644 actionview/test/fixtures/test/_cached_customer_as.erb diff --git a/actionpack/test/controller/caching_test.rb b/actionpack/test/controller/caching_test.rb index 4760ec1698..2d6607041d 100644 --- a/actionpack/test/controller/caching_test.rb +++ b/actionpack/test/controller/caching_test.rb @@ -1,5 +1,6 @@ require 'fileutils' require 'abstract_unit' +require 'lib/controller/fake_models' CACHE_DIR = 'test_cache' # Don't change '/../temp/' cavalierly or you might hose something you don't want hosed @@ -349,3 +350,60 @@ class ViewCacheDependencyTest < ActionController::TestCase assert_equal %w(trombone flute), HasDependenciesController.new.view_cache_dependencies end end + +class CollectionCacheController < ActionController::Base + def index + @customers = [Customer.new('david', params[:id] || 1)] + end + + def index_ordered + @customers = [Customer.new('david', 1), Customer.new('david', 2), Customer.new('david', 3)] + render 'index' + end + + def index_explicit_render + @customers = [Customer.new('david', 1)] + render partial: 'customers/customer', collection: @customers + end + + def index_with_comment + @customers = [Customer.new('david', 1)] + render partial: 'customers/commented_customer', collection: @customers, as: :customer + end +end + +class AutomaticCollectionCacheTest < ActionController::TestCase + def setup + super + @controller = CollectionCacheController.new + @controller.perform_caching = true + @controller.cache_store = ActiveSupport::Cache::MemoryStore.new + end + + def test_collection_fetches_cached_views + get :index + + ActionView::PartialRenderer.expects(:collection_with_template).never + get :index + end + + def test_preserves_order_when_reading_from_cache_plus_rendering + get :index, params: { id: 2 } + get :index_ordered + + assert_select ':root', "david, 1\n david, 2\n david, 3" + end + + def test_explicit_render_call_with_options + get :index_explicit_render + + assert_select ':root', "david, 1" + end + + def test_caching_works_with_beginning_comment + get :index_with_comment + + ActionView::PartialRenderer.expects(:collection_with_template).never + get :index_with_comment + end +end diff --git a/actionpack/test/fixtures/collection_cache/index.html.erb b/actionpack/test/fixtures/collection_cache/index.html.erb new file mode 100644 index 0000000000..521b1450df --- /dev/null +++ b/actionpack/test/fixtures/collection_cache/index.html.erb @@ -0,0 +1 @@ +<%= render @customers %> \ No newline at end of file diff --git a/actionpack/test/fixtures/customers/_commented_customer.html.erb b/actionpack/test/fixtures/customers/_commented_customer.html.erb new file mode 100644 index 0000000000..d5f6e3b491 --- /dev/null +++ b/actionpack/test/fixtures/customers/_commented_customer.html.erb @@ -0,0 +1,4 @@ +<%# I'm a comment %> +<% cache customer do %> + <%= customer.name %>, <%= customer.id %> +<% end %> \ No newline at end of file diff --git a/actionpack/test/fixtures/customers/_customer.html.erb b/actionpack/test/fixtures/customers/_customer.html.erb new file mode 100644 index 0000000000..67e9f6d411 --- /dev/null +++ b/actionpack/test/fixtures/customers/_customer.html.erb @@ -0,0 +1,3 @@ +<% cache customer do %> + <%= customer.name %>, <%= customer.id %> +<% end %> \ No newline at end of file diff --git a/actionview/lib/action_view/helpers/cache_helper.rb b/actionview/lib/action_view/helpers/cache_helper.rb index 56b1c5049c..dec8c9cee9 100644 --- a/actionview/lib/action_view/helpers/cache_helper.rb +++ b/actionview/lib/action_view/helpers/cache_helper.rb @@ -110,6 +110,29 @@ module ActionView # <%= some_helper_method(person) %> # # Now all you'll have to do is change that timestamp when the helper method changes. + # + # === Automatic Collection Caching + # + # When rendering collections such as: + # + # <%= render @notifications %> + # <%= render partial: 'notifications/notification', collection: @notifications %> + # + # If the notifications/_notification partial starts with a cache call like so: + # + # <% cache notification do %> + # <%= notification.name %> + # <% end %> + # + # The collection can then automatically use any cached renders for that + # template by reading them at once instead of one by one. + # + # See ActionView::Template::Handlers::ERB.resource_cache_call_pattern for more + # information on what cache calls make a template eligible for this collection caching. + # + # The automatic cache multi read can be turned off like so: + # + # <%= render @notifications, cache: false %> def cache(name = {}, options = nil, &block) if controller.perform_caching safe_concat(fragment_for(cache_fragment_name(name, options), options, &block)) diff --git a/actionview/lib/action_view/renderer/partial_renderer/collection_caching.rb b/actionview/lib/action_view/renderer/partial_renderer/collection_caching.rb index 0f2ab887bc..b77c884e66 100644 --- a/actionview/lib/action_view/renderer/partial_renderer/collection_caching.rb +++ b/actionview/lib/action_view/renderer/partial_renderer/collection_caching.rb @@ -26,11 +26,24 @@ module ActionView end def cache_collection? - @options[:cache].present? + @options.fetch(:cache, automatic_cache_eligible?) + end + + def automatic_cache_eligible? + single_template_render? && !callable_cache_key? && + @template.eligible_for_collection_caching?(as: @options[:as]) + end + + def single_template_render? + @template # Template is only set when a collection renders one template. + end + + def callable_cache_key? + @options[:cache].respond_to?(:call) end def collection_by_cache_keys - seed = @options[:cache].respond_to?(:call) ? @options[:cache] : ->(i) { i } + seed = callable_cache_key? ? @options[:cache] : ->(i) { i } @collection.each_with_object({}) do |item, hash| hash[expanded_cache_key(seed.call(item))] = item diff --git a/actionview/lib/action_view/template.rb b/actionview/lib/action_view/template.rb index 6b61378a1f..4eae3e2830 100644 --- a/actionview/lib/action_view/template.rb +++ b/actionview/lib/action_view/template.rb @@ -117,6 +117,7 @@ module ActionView @source = source @identifier = identifier @handler = handler + @cache_name = extract_resource_cache_call_name @compiled = false @original_encoding = nil @locals = details[:locals] || [] @@ -152,6 +153,10 @@ module ActionView @type ||= Types[@formats.first] if @formats.first end + def eligible_for_collection_caching?(as: nil) + @cache_name == (as || inferred_cache_name).to_s + end + # Receives a view object and return a template similar to self by using @virtual_path. # # This method is useful if you have a template object but it does not contain its source @@ -332,5 +337,14 @@ module ActionView payload = { virtual_path: @virtual_path, identifier: @identifier } ActiveSupport::Notifications.instrument("#{action}.action_view", payload, &block) end + + def extract_resource_cache_call_name + $1 if @handler.respond_to?(:resource_cache_call_pattern) && + @source =~ @handler.resource_cache_call_pattern + end + + def inferred_cache_name + @inferred_cache_name ||= @virtual_path.split('/').last.sub('_', '') + end end end diff --git a/actionview/lib/action_view/template/handlers/erb.rb b/actionview/lib/action_view/template/handlers/erb.rb index 85a100ed4c..88a8570706 100644 --- a/actionview/lib/action_view/template/handlers/erb.rb +++ b/actionview/lib/action_view/template/handlers/erb.rb @@ -123,6 +123,24 @@ module ActionView ).src end + # Returns Regexp to extract a cached resource's name from a cache call at the + # first line of a template. + # The extracted cache name is expected in $1. + # + # <% cache notification do %> # => notification + # + # The pattern should support templates with a beginning comment: + # + # <%# Still extractable even though there's a comment %> + # <% cache notification do %> # => notification + # + # But fail to extract a name if a resource association is cached. + # + # <% cache notification.event do %> # => nil + def resource_cache_call_pattern + /\A(?:<%#.*%>\n?)?<% cache\(?\s*(\w+\.?)/ + end + private def valid_encoding(string, encoding) diff --git a/actionview/test/fixtures/test/_cached_customer.erb b/actionview/test/fixtures/test/_cached_customer.erb new file mode 100644 index 0000000000..52f35a3497 --- /dev/null +++ b/actionview/test/fixtures/test/_cached_customer.erb @@ -0,0 +1,3 @@ +<% cache cached_customer do %> + Hello: <%= cached_customer.name %> +<% end %> \ No newline at end of file diff --git a/actionview/test/fixtures/test/_cached_customer_as.erb b/actionview/test/fixtures/test/_cached_customer_as.erb new file mode 100644 index 0000000000..fca8d19e34 --- /dev/null +++ b/actionview/test/fixtures/test/_cached_customer_as.erb @@ -0,0 +1,3 @@ +<% cache buyer do %> + <%= greeting %>: <%= customer.name %> +<% end %> \ No newline at end of file diff --git a/actionview/test/template/render_test.rb b/actionview/test/template/render_test.rb index 73871cf974..691636a8f9 100644 --- a/actionview/test/template/render_test.rb +++ b/actionview/test/template/render_test.rb @@ -599,6 +599,12 @@ class LazyViewRenderTest < ActiveSupport::TestCase end class CachedCollectionViewRenderTest < CachedViewRenderTest + class CachedCustomer < Customer; end + + teardown do + ActionView::PartialRenderer.collection_cache.clear + end + test "with custom key" do customer = Customer.new("david") key = ActionController::Base.new.fragment_cache_key([customer, 'key']) @@ -607,7 +613,25 @@ class CachedCollectionViewRenderTest < CachedViewRenderTest assert_equal "Hello", @view.render(partial: "test/customer", collection: [customer], cache: ->(item) { [item, 'key'] }) + end - ActionView::PartialRenderer.collection_cache.clear + test "automatic caching with inferred cache name" do + customer = CachedCustomer.new("david") + key = ActionController::Base.new.fragment_cache_key(customer) + + ActionView::PartialRenderer.collection_cache.write(key, 'Cached') + + assert_equal "Cached", + @view.render(partial: "test/cached_customer", collection: [customer]) + end + + test "automatic caching with as name" do + customer = CachedCustomer.new("david") + key = ActionController::Base.new.fragment_cache_key(customer) + + ActionView::PartialRenderer.collection_cache.write(key, 'Cached') + + assert_equal "Cached", + @view.render(partial: "test/cached_customer_as", collection: [customer], as: :buyer) end end