Merge pull request #38594 from rails/collection-refactor

Refactoring PartialRenderer
This commit is contained in:
Aaron Patterson 2020-03-23 17:41:20 -07:00 committed by GitHub
commit fc4ef77d47
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 387 additions and 342 deletions

View File

@ -51,6 +51,8 @@ module ActionView
autoload :Renderer
autoload :AbstractRenderer
autoload :PartialRenderer
autoload :CollectionRenderer
autoload :ObjectRenderer
autoload :TemplateRenderer
autoload :StreamingTemplateRenderer
end

View File

@ -1,5 +1,7 @@
# frozen_string_literal: true
require "concurrent/map"
module ActionView
# This class defines the interface for a renderer. Each class that
# subclasses +AbstractRenderer+ is used by the base +Renderer+ class to
@ -27,6 +29,86 @@ module ActionView
raise NotImplementedError
end
module ObjectRendering # :nodoc:
PREFIXED_PARTIAL_NAMES = Concurrent::Map.new do |h, k|
h[k] = Concurrent::Map.new
end
def initialize(lookup_context, options)
super
@context_prefix = lookup_context.prefixes.first
end
private
def local_variable(path)
if as = @options[:as]
raise_invalid_option_as(as) unless /\A[a-z_]\w*\z/.match?(as.to_s)
as.to_sym
else
begin
base = path[-1] == "/" ? "" : File.basename(path)
raise_invalid_identifier(path) unless base =~ /\A_?(.*?)(?:\.\w+)*\z/
$1.to_sym
end
end
end
IDENTIFIER_ERROR_MESSAGE = "The partial name (%s) is not a valid Ruby identifier; " \
"make sure your partial name starts with underscore."
OPTION_AS_ERROR_MESSAGE = "The value (%s) of the option `as` is not a valid Ruby identifier; " \
"make sure it starts with lowercase letter, " \
"and is followed by any combination of letters, numbers and underscores."
def raise_invalid_identifier(path)
raise ArgumentError, IDENTIFIER_ERROR_MESSAGE % path
end
def raise_invalid_option_as(as)
raise ArgumentError, OPTION_AS_ERROR_MESSAGE % as
end
# Obtains the path to where the object's partial is located. If the object
# responds to +to_partial_path+, then +to_partial_path+ will be called and
# will provide the path. If the object does not respond to +to_partial_path+,
# then an +ArgumentError+ is raised.
#
# If +prefix_partial_path_with_controller_namespace+ is true, then this
# method will prefix the partial paths with a namespace.
def partial_path(object, view)
object = object.to_model if object.respond_to?(:to_model)
path = if object.respond_to?(:to_partial_path)
object.to_partial_path
else
raise ArgumentError.new("'#{object.inspect}' is not an ActiveModel-compatible object. It must implement :to_partial_path.")
end
if view.prefix_partial_path_with_controller_namespace
PREFIXED_PARTIAL_NAMES[@context_prefix][path] ||= merge_prefix_into_object_path(@context_prefix, path.dup)
else
path
end
end
def merge_prefix_into_object_path(prefix, object_path)
if prefix.include?(?/) && object_path.include?(?/)
prefixes = []
prefix_array = File.dirname(prefix).split("/")
object_path_array = object_path.split("/")[0..-3] # skip model dir & partial
prefix_array.each_with_index do |dir, index|
break if dir == object_path_array[index]
prefixes << dir
end
(prefixes << object_path).join("/")
else
object_path
end
end
end
class RenderedCollection # :nodoc:
def self.empty(format)
EmptyCollection.new format
@ -74,12 +156,18 @@ module ActionView
end
private
NO_DETAILS = {}.freeze
def extract_details(options) # :doc:
@lookup_context.registered_details.each_with_object({}) do |key, details|
details = nil
@lookup_context.registered_details.each do |key|
value = options[key]
details[key] = Array(value) if value
if value
(details ||= {})[key] = Array(value)
end
end
details || NO_DETAILS
end
def instrument(name, **options) # :doc:

View File

@ -0,0 +1,188 @@
# frozen_string_literal: true
require "action_view/renderer/partial_renderer"
module ActionView
class PartialIteration
# The number of iterations that will be done by the partial.
attr_reader :size
# The current iteration of the partial.
attr_reader :index
def initialize(size)
@size = size
@index = 0
end
# Check if this is the first iteration of the partial.
def first?
index == 0
end
# Check if this is the last iteration of the partial.
def last?
index == size - 1
end
def iterate! # :nodoc:
@index += 1
end
end
class CollectionRenderer < PartialRenderer # :nodoc:
include ObjectRendering
class CollectionIterator # :nodoc:
include Enumerable
def initialize(collection)
@collection = collection
end
def each(&blk)
@collection.each(&blk)
end
def size
@collection.size
end
end
class SameCollectionIterator < CollectionIterator # :nodoc:
def initialize(collection, path, variables)
super(collection)
@path = path
@variables = variables
end
def from_collection(collection)
self.class.new(collection, @path, @variables)
end
def each_with_info
return enum_for(:each_with_info) unless block_given?
variables = [@path] + @variables
@collection.each { |o| yield(o, variables) }
end
end
class PreloadCollectionIterator < SameCollectionIterator # :nodoc:
def initialize(collection, path, variables, relation)
super(collection, path, variables)
relation.skip_preloading! unless relation.loaded?
@relation = relation
end
def from_collection(collection)
self.class.new(collection, @path, @variables, @relation)
end
def each_with_info
return super unless block_given?
@relation.preload_associations(@collection)
super
end
end
class MixedCollectionIterator < CollectionIterator # :nodoc:
def initialize(collection, paths)
super(collection)
@paths = paths
end
def each_with_info
return enum_for(:each_with_info) unless block_given?
@collection.each_with_index { |o, i| yield(o, @paths[i]) }
end
end
def render_collection_with_partial(collection, partial, context, block)
iter_vars = retrieve_variable(partial)
collection = if collection.respond_to?(:preload_associations)
PreloadCollectionIterator.new(collection, partial, iter_vars, collection)
else
SameCollectionIterator.new(collection, partial, iter_vars)
end
template = find_template(partial, @locals.keys + iter_vars)
layout = if !block && (layout = @options[:layout])
find_template(layout.to_s, @locals.keys + iter_vars)
end
render_collection(collection, context, partial, template, layout, block)
end
def render_collection_derive_partial(collection, context, block)
paths = collection.map { |o| partial_path(o, context) }
if paths.uniq.length == 1
# Homogeneous
render_collection_with_partial(collection, paths.first, context, block)
else
if @options[:cached]
raise NotImplementedError, "render caching requires a template. Please specify a partial when rendering"
end
paths.map! { |path| retrieve_variable(path).unshift(path) }
collection = MixedCollectionIterator.new(collection, paths)
render_collection(collection, context, nil, nil, nil, block)
end
end
private
def retrieve_variable(path)
variable = local_variable(path)
[variable, :"#{variable}_counter", :"#{variable}_iteration"]
end
def render_collection(collection, view, path, template, layout, block)
identifier = (template && template.identifier) || path
instrument(:collection, identifier: identifier, count: collection.size) do |payload|
spacer = if @options.key?(:spacer_template)
spacer_template = find_template(@options[:spacer_template], @locals.keys)
build_rendered_template(spacer_template.render(view, @locals), spacer_template)
else
RenderedTemplate::EMPTY_SPACER
end
collection_body = if template
cache_collection_render(payload, view, template, collection) do |filtered_collection|
collection_with_template(view, template, layout, filtered_collection)
end
else
collection_with_template(view, nil, layout, collection)
end
return RenderedCollection.empty(@lookup_context.formats.first) if collection_body.empty?
build_rendered_collection(collection_body, spacer)
end
end
def collection_with_template(view, template, layout, collection)
locals = @locals
cache = {}
partial_iteration = PartialIteration.new(collection.size)
collection.each_with_info.map do |object, (path, as, counter, iteration)|
index = partial_iteration.index
locals[as] = object
locals[counter] = index
locals[iteration] = partial_iteration
_template = (cache[path] ||= (template || find_template(path, @locals.keys + [as, counter, iteration])))
content = _template.render(view, locals)
content = layout.render(view, locals) { content } if layout
partial_iteration.iterate!
build_rendered_template(content, _template)
end
end
end
end

View File

@ -0,0 +1,34 @@
# frozen_string_literal: true
module ActionView
class ObjectRenderer < PartialRenderer # :nodoc:
include ObjectRendering
def initialize(lookup_context, options)
super
@object = nil
@local_name = nil
end
def render_object_with_partial(object, partial, context, block)
@object = object
@local_name = local_variable(partial)
render(partial, context, block)
end
def render_object_derive_partial(object, context, block)
path = partial_path(object, context)
render_object_with_partial(object, path, context, block)
end
private
def template_keys(path)
super + [@local_name]
end
def render_partial_template(view, locals, template, layout, block)
locals[@local_name || template.variable] = @object
super(view, locals, template, layout, block)
end
end
end

View File

@ -1,36 +1,8 @@
# frozen_string_literal: true
require "concurrent/map"
require "action_view/renderer/partial_renderer/collection_caching"
module ActionView
class PartialIteration
# The number of iterations that will be done by the partial.
attr_reader :size
# The current iteration of the partial.
attr_reader :index
def initialize(size)
@size = size
@index = 0
end
# Check if this is the first iteration of the partial.
def first?
index == 0
end
# Check if this is the last iteration of the partial.
def last?
index == size - 1
end
def iterate! # :nodoc:
@index += 1
end
end
# = Action View Partials
#
# There's also a convenience method for rendering sub templates within the current controller that depends on a
@ -282,78 +254,30 @@ module ActionView
class PartialRenderer < AbstractRenderer
include CollectionCaching
PREFIXED_PARTIAL_NAMES = Concurrent::Map.new do |h, k|
h[k] = Concurrent::Map.new
def initialize(lookup_context, options)
super(lookup_context)
@options = options
@locals = @options[:locals] || {}
@details = extract_details(@options)
end
def initialize(*)
super
@context_prefix = @lookup_context.prefixes.first
end
def render(partial, context, block)
template = find_template(partial, template_keys(partial))
def render(context, options, block)
as = as_variable(options)
setup(context, options, as, block)
if @path
if @has_object || @collection
@variable, @variable_counter, @variable_iteration = retrieve_variable(@path, as)
@template_keys = retrieve_template_keys(@variable)
else
@template_keys = @locals.keys
end
template = find_template(@path, @template_keys)
@variable ||= template.variable
else
if options[:cached]
raise NotImplementedError, "render caching requires a template. Please specify a partial when rendering"
end
template = nil
if !block && (layout = @options[:layout])
layout = find_template(layout.to_s, template_keys(partial))
end
if @collection
render_collection(context, template)
else
render_partial(context, template)
end
render_partial_template(context, @locals, template, layout, block)
end
private
def render_collection(view, template)
identifier = (template && template.identifier) || @path
instrument(:collection, identifier: identifier, count: @collection.size) do |payload|
return RenderedCollection.empty(@lookup_context.formats.first) if @collection.blank?
spacer = if @options.key?(:spacer_template)
spacer_template = find_template(@options[:spacer_template], @locals.keys)
build_rendered_template(spacer_template.render(view, @locals), spacer_template)
else
RenderedTemplate::EMPTY_SPACER
end
collection_body = if template
cache_collection_render(payload, view, template) do
collection_with_template(view, template)
end
else
collection_without_template(view)
end
build_rendered_collection(collection_body, spacer)
end
def template_keys(_)
@locals.keys
end
def render_partial(view, template)
def render_partial_template(view, locals, template, layout, block)
instrument(:partial, identifier: template.identifier) do |payload|
locals, block = @locals, @block
object, as = @object, @variable
if !block && (layout = @options[:layout])
layout = find_template(layout.to_s, @template_keys)
end
object = locals[as] if object.nil? # Respect object when object is false
locals[as] = object if @has_object
content = template.render(view, locals) do |*name|
view._layout_for(*name, &block)
end
@ -364,195 +288,9 @@ module ActionView
end
end
# Sets up instance variables needed for rendering a partial. This method
# finds the options and details and extracts them. The method also contains
# logic that handles the type of object passed in as the partial.
#
# If +options[:partial]+ is a string, then the <tt>@path</tt> instance variable is
# set to that string. Otherwise, the +options[:partial]+ object must
# respond to +to_partial_path+ in order to set up the path.
def setup(context, options, as, block)
@options = options
@block = block
@locals = options[:locals] || {}
@details = extract_details(options)
partial = options[:partial]
if String === partial
@has_object = options.key?(:object)
@object = options[:object]
@collection = collection_from_options
@path = partial
else
@has_object = true
@object = partial
@collection = collection_from_object || collection_from_options
if @collection
paths = @collection_data = @collection.map { |o| partial_path(o, context) }
if paths.uniq.length == 1
@path = paths.first
else
paths.map! { |path| retrieve_variable(path, as).unshift(path) }
@path = nil
end
else
@path = partial_path(@object, context)
end
end
self
end
def as_variable(options)
if as = options[:as]
raise_invalid_option_as(as) unless /\A[a-z_]\w*\z/.match?(as.to_s)
as.to_sym
end
end
def collection_from_options
if @options.key?(:collection)
collection = @options[:collection]
collection ? collection.to_a : []
end
end
def collection_from_object
@object.to_ary if @object.respond_to?(:to_ary)
end
def find_template(path, locals)
prefixes = path.include?(?/) ? [] : @lookup_context.prefixes
@lookup_context.find_template(path, prefixes, true, locals, @details)
end
def collection_with_template(view, template)
locals = @locals
as, counter, iteration = @variable, @variable_counter, @variable_iteration
if layout = @options[:layout]
layout = find_template(layout, @template_keys)
end
partial_iteration = PartialIteration.new(@collection.size)
locals[iteration] = partial_iteration
@collection.map do |object|
locals[as] = object
locals[counter] = partial_iteration.index
content = template.render(view, locals)
content = layout.render(view, locals) { content } if layout
partial_iteration.iterate!
build_rendered_template(content, template)
end
end
def collection_without_template(view)
locals, collection_data = @locals, @collection_data
cache = {}
keys = @locals.keys
partial_iteration = PartialIteration.new(@collection.size)
@collection.map do |object|
index = partial_iteration.index
path, as, counter, iteration = collection_data[index]
locals[as] = object
locals[counter] = index
locals[iteration] = partial_iteration
template = (cache[path] ||= find_template(path, keys + [as, counter, iteration]))
content = template.render(view, locals)
partial_iteration.iterate!
build_rendered_template(content, template)
end
end
# Obtains the path to where the object's partial is located. If the object
# responds to +to_partial_path+, then +to_partial_path+ will be called and
# will provide the path. If the object does not respond to +to_partial_path+,
# then an +ArgumentError+ is raised.
#
# If +prefix_partial_path_with_controller_namespace+ is true, then this
# method will prefix the partial paths with a namespace.
def partial_path(object, view)
object = object.to_model if object.respond_to?(:to_model)
path = if object.respond_to?(:to_partial_path)
object.to_partial_path
else
raise ArgumentError.new("'#{object.inspect}' is not an ActiveModel-compatible object. It must implement :to_partial_path.")
end
if view.prefix_partial_path_with_controller_namespace
prefixed_partial_names[path] ||= merge_prefix_into_object_path(@context_prefix, path.dup)
else
path
end
end
def prefixed_partial_names
@prefixed_partial_names ||= PREFIXED_PARTIAL_NAMES[@context_prefix]
end
def merge_prefix_into_object_path(prefix, object_path)
if prefix.include?(?/) && object_path.include?(?/)
prefixes = []
prefix_array = File.dirname(prefix).split("/")
object_path_array = object_path.split("/")[0..-3] # skip model dir & partial
prefix_array.each_with_index do |dir, index|
break if dir == object_path_array[index]
prefixes << dir
end
(prefixes << object_path).join("/")
else
object_path
end
end
def retrieve_template_keys(variable)
keys = @locals.keys
keys << variable
if @collection
keys << @variable_counter
keys << @variable_iteration
end
keys
end
def retrieve_variable(path, as)
variable = as || begin
base = path[-1] == "/" ? "" : File.basename(path)
raise_invalid_identifier(path) unless base =~ /\A_?(.*?)(?:\.\w+)*\z/
$1.to_sym
end
if @collection
variable_counter = :"#{variable}_counter"
variable_iteration = :"#{variable}_iteration"
end
[variable, variable_counter, variable_iteration]
end
IDENTIFIER_ERROR_MESSAGE = "The partial name (%s) is not a valid Ruby identifier; " \
"make sure your partial name starts with underscore."
OPTION_AS_ERROR_MESSAGE = "The value (%s) of the option `as` is not a valid Ruby identifier; " \
"make sure it starts with lowercase letter, " \
"and is followed by any combination of letters, numbers and underscores."
def raise_invalid_identifier(path)
raise ArgumentError.new(IDENTIFIER_ERROR_MESSAGE % (path))
end
def raise_invalid_option_as(as)
raise ArgumentError.new(OPTION_AS_ERROR_MESSAGE % (as))
end
end
end

View File

@ -13,13 +13,19 @@ module ActionView
end
private
def cache_collection_render(instrumentation_payload, view, template)
return yield unless @options[:cached] && view.controller.respond_to?(:perform_caching) && view.controller.perform_caching
def will_cache?(options, view)
options[:cached] && view.controller.respond_to?(:perform_caching) && view.controller.perform_caching
end
def cache_collection_render(instrumentation_payload, view, template, collection)
return yield(collection) unless will_cache?(@options, view)
collection_iterator = collection
# Result is a hash with the key represents the
# key used for cache lookup and the value is the item
# on which the partial is being rendered
keyed_collection, ordered_keys = collection_by_cache_keys(view, template)
keyed_collection, ordered_keys = collection_by_cache_keys(view, template, collection)
# Pull all partials from cache
# Result is a hash, key matches the entry in
@ -29,17 +35,9 @@ module ActionView
instrumentation_payload[:cache_hits] = cached_partials.size
# Extract the items for the keys that are not found
# Set the uncached values to instance variable @collection
# which is used by the caller
@collection = keyed_collection.reject { |key, _| cached_partials.key?(key) }.values
collection = keyed_collection.reject { |key, _| cached_partials.key?(key) }.values
# If all elements are already in cache then
# rendered partials will be an empty array
#
# If the cache is missing elements then
# the block will be called against the remaining items
# in the @collection.
rendered_partials = @collection.empty? ? [] : yield
rendered_partials = collection.empty? ? [] : yield(collection_iterator.from_collection(collection))
index = 0
keyed_partials = fetch_or_cache_partial(cached_partials, template, order_by: keyed_collection.each_key) do
@ -57,12 +55,12 @@ module ActionView
@options[:cached].respond_to?(:call)
end
def collection_by_cache_keys(view, template)
def collection_by_cache_keys(view, template, collection)
seed = callable_cache_key? ? @options[:cached] : ->(i) { i }
digest_path = view.digest_path_from_template(template)
@collection.each_with_object([{}, []]) do |item, (hash, ordered_keys)|
collection.each_with_object([{}, []]) do |item, (hash, ordered_keys)|
key = expanded_cache_key(seed.call(item), view, template, digest_path)
ordered_keys << key
hash[key] = item

View File

@ -65,7 +65,50 @@ module ActionView
end
def render_partial_to_object(context, options, &block) #:nodoc:
PartialRenderer.new(@lookup_context).render(context, options, block)
partial = options[:partial]
if String === partial
collection = collection_from_options(options)
if collection
# Collection + Partial
renderer = CollectionRenderer.new(@lookup_context, options)
renderer.render_collection_with_partial(collection, partial, context, block)
else
if options.key?(:object)
# Object + Partial
renderer = ObjectRenderer.new(@lookup_context, options)
renderer.render_object_with_partial(options[:object], partial, context, block)
else
# Partial
renderer = PartialRenderer.new(@lookup_context, options)
renderer.render(partial, context, block)
end
end
else
collection = collection_from_object(partial) || collection_from_options(options)
if collection
# Collection + Derived Partial
renderer = CollectionRenderer.new(@lookup_context, options)
renderer.render_collection_derive_partial(collection, context, block)
else
# Object + Derived Partial
renderer = ObjectRenderer.new(@lookup_context, options)
renderer.render_object_derive_partial(partial, context, block)
end
end
end
private
def collection_from_options(options)
if options.key?(:collection)
collection = options[:collection]
collection || []
end
end
def collection_from_object(object)
object if object.respond_to?(:to_ary)
end
end
end

View File

@ -1,9 +1,6 @@
# frozen_string_literal: true
require "active_record_unit"
require "active_record/railties/collection_cache_association_loading"
ActionView::PartialRenderer.prepend(ActiveRecord::Railties::CollectionCacheAssociationLoading)
class MultifetchCacheTest < ActiveRecordTestCase
fixtures :topics, :replies

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true
require "abstract_unit"
require "action_view/renderer/partial_renderer"
require "action_view/renderer/collection_renderer"
class PartialIterationTest < ActiveSupport::TestCase
def test_has_size_and_index

View File

@ -265,14 +265,14 @@ module RenderTestCases
end
def test_render_partial_with_invalid_option_as
e = assert_raises(ArgumentError) { @view.render(partial: "test/partial_only", as: "a-in") }
e = assert_raises(ArgumentError) { @view.render(partial: "test/partial_only", as: "a-in", object: nil) }
assert_equal "The value (a-in) of the option `as` is not a valid Ruby identifier; " \
"make sure it starts with lowercase letter, " \
"and is followed by any combination of letters, numbers and underscores.", e.message
end
def test_render_partial_with_hyphen_and_invalid_option_as
e = assert_raises(ArgumentError) { @view.render(partial: "test/a-in", as: "a-in") }
e = assert_raises(ArgumentError) { @view.render(partial: "test/a-in", as: "a-in", object: nil) }
assert_equal "The value (a-in) of the option `as` is not a valid Ruby identifier; " \
"make sure it starts with lowercase letter, " \
"and is followed by any combination of letters, numbers and underscores.", e.message

View File

@ -221,13 +221,6 @@ To keep using the current cache store, you can turn off cache versioning entirel
end
end
initializer "active_record.collection_cache_association_loading" do
require "active_record/railties/collection_cache_association_loading"
ActiveSupport.on_load(:action_view) do
ActionView::PartialRenderer.prepend(ActiveRecord::Railties::CollectionCacheAssociationLoading)
end
end
initializer "active_record.set_reloader_hooks" do
ActiveSupport.on_load(:active_record) do
ActiveSupport::Reloader.before_class_unload do

View File

@ -1,36 +0,0 @@
# frozen_string_literal: true
module ActiveRecord
module Railties # :nodoc:
module CollectionCacheAssociationLoading #:nodoc:
def setup(context, options, as, block)
@relation = nil
return super unless options[:cached]
@relation = relation_from_options(options[:partial], options[:collection])
super
end
def relation_from_options(partial, collection)
relation = partial if partial.is_a?(ActiveRecord::Relation)
relation ||= collection if collection.is_a?(ActiveRecord::Relation)
if relation && !relation.loaded?
relation.skip_preloading!
end
end
def collection_without_template(*)
@relation.preload_associations(@collection) if @relation
super
end
def collection_with_template(*)
@relation.preload_associations(@collection) if @relation
super
end
end
end
end