1
0
Fork 0
mirror of https://github.com/rails/rails.git synced 2022-11-09 12:12:34 -05:00
rails--rails/actionview/lib/action_view/renderer/collection_renderer.rb
aaron 432698ef2b
Fix SELECT COUNT queries when rendering ActiveRecord collections (#40870)
* Fix `SELECT COUNT` queries when rendering ActiveRecord collections

Fixes #40837

When rendering collections, calling `size` when the collection is an
ActiveRecord relation causes unwanted `SELECT COUNT(*)` queries. This
change ensures the collection is an array before getting the size, and
also loads the relation for any further array inspections.

* Test queries when rendering relation collections

* Add `length` support to partial collection iterator

Allows getting the size of a relation without duplicating records, but
still loads the relation. The length method existence needs to be
checked because you can pass in an `Enumerator`, which does not respond
to `length`.

* Ensure unsubscribed from notifications after tests

[Rafael Mendonça França + aar0nr]
2020-12-18 15:58:11 -05:00

196 lines
5.8 KiB
Ruby

# 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
def length
@collection.respond_to?(:length) ? @collection.length : 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
ActiveSupport::Notifications.instrument(
"render_collection.action_view",
identifier: identifier,
layout: layout && layout.virtual_path,
count: collection.length
) 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