Skip to content

Commit

Permalink
Speed up collection rendering and support collection caching (#501)
Browse files Browse the repository at this point in the history
  • Loading branch information
yuki24 committed Nov 14, 2021
1 parent 606747f commit d84c37d
Show file tree
Hide file tree
Showing 5 changed files with 241 additions and 12 deletions.
24 changes: 17 additions & 7 deletions README.md
Expand Up @@ -188,19 +188,19 @@ It's also possible to render collections of partials:
json.array! @posts, partial: 'posts/post', as: :post

# or

json.partial! 'posts/post', collection: @posts, as: :post

# or

json.partial! partial: 'posts/post', collection: @posts, as: :post

# or

json.comments @post.comments, partial: 'comments/comment', as: :comment
```

The `as: :some_symbol` is used with partials. It will take care of mapping the passed in object to a variable for the partial. If the value is a collection (either implicitly or explicitly by using the `collection:` option, then each value of the collection is passed to the partial as the variable `some_symbol`. If the value is a singular object, then the object is passed to the partial as the variable `some_symbol`.
The `as: :some_symbol` is used with partials. It will take care of mapping the passed in object to a variable for the
partial. If the value is a collection (either implicitly or explicitly by using the `collection:` option, then each
value of the collection is passed to the partial as the variable `some_symbol`. If the value is a singular object,
then the object is passed to the partial as the variable `some_symbol`.

Be sure not to confuse the `as:` option to mean nesting of the partial. For example:

Expand Down Expand Up @@ -253,6 +253,8 @@ json.bar "bar"
# => { "bar": "bar" }
```

## Caching

Fragment caching is supported, it uses `Rails.cache` and works like caching in
HTML templates:

Expand All @@ -270,9 +272,17 @@ json.cache_if! !admin?, ['v1', @person], expires_in: 10.minutes do
end
```

If you are rendering fragments for a collection of objects, have a look at
`jbuilder_cache_multi` gem. It uses fetch_multi (>= Rails 4.1) to fetch
multiple keys at once.
Aside from that, the `:cached` options on collection rendering is available on Rails >= 6.0. This will cache the
rendered results effectively using the multi fetch feature.

```
json.array! @posts, partial: "posts/post", as: :post, cached: true
# or:
json.comments @post.comments, partial: "comments/comment", as: :comment, cached: true
```

## Formatting Keys

Keys can be auto formatted using `key_format!`, this can be used to convert
keynames from the standard ruby_format to camelCase:
Expand Down
108 changes: 108 additions & 0 deletions lib/jbuilder/collection_renderer.rb
@@ -0,0 +1,108 @@
require 'delegate'
require 'active_support/concern'

begin
require 'action_view/renderer/collection_renderer'
rescue LoadError
require 'action_view/renderer/partial_renderer'
end

class Jbuilder
module CollectionRenderable # :nodoc:
extend ActiveSupport::Concern

class_methods do
def supported?
superclass.private_method_defined?(:build_rendered_template) && self.superclass.private_method_defined?(:build_rendered_collection)
end
end

private

def build_rendered_template(content, template, layout = nil)
super(content || json.attributes!, template)
end

def build_rendered_collection(templates, _spacer)
json.merge!(templates.map(&:body))
end

def json
@options[:locals].fetch(:json)
end

class ScopedIterator < ::SimpleDelegator # :nodoc:
include Enumerable

def initialize(obj, scope)
super(obj)
@scope = scope
end

# Rails 6.0 support:
def each
return enum_for(:each) unless block_given?

__getobj__.each do |object|
@scope.call { yield(object) }
end
end

# Rails 6.1 support:
def each_with_info
return enum_for(:each_with_info) unless block_given?

__getobj__.each_with_info do |object, info|
@scope.call { yield(object, info) }
end
end
end

private_constant :ScopedIterator
end

if defined?(::ActionView::CollectionRenderer)
# Rails 6.1 support:
class CollectionRenderer < ::ActionView::CollectionRenderer # :nodoc:
include CollectionRenderable

def initialize(lookup_context, options, &scope)
super(lookup_context, options)
@scope = scope
end

private
def collection_with_template(view, template, layout, collection)
super(view, template, layout, ScopedIterator.new(collection, @scope))
end
end
else
# Rails 6.0 support:
class CollectionRenderer < ::ActionView::PartialRenderer # :nodoc:
include CollectionRenderable

def initialize(lookup_context, options, &scope)
super(lookup_context)
@options = options
@scope = scope
end

def render_collection_with_partial(collection, partial, context, block)
render(context, @options.merge(collection: collection, partial: partial), block)
end

private
def collection_without_template(view)
@collection = ScopedIterator.new(@collection, @scope)

super(view)
end

def collection_with_template(view, template)
@collection = ScopedIterator.new(@collection, @scope)

super(view, template)
end
end
end
end
56 changes: 54 additions & 2 deletions lib/jbuilder/jbuilder_template.rb
@@ -1,4 +1,5 @@
require 'jbuilder/jbuilder'
require 'jbuilder/collection_renderer'
require 'action_dispatch/http/mime_type'
require 'active_support/cache'

Expand All @@ -15,6 +16,38 @@ def initialize(context, *args)
super(*args)
end

# Generates JSON using the template specified with the `:partial` option. For example, the code below will render
# the file `views/comments/_comments.json.jbuilder`, and set a local variable comments with all this message's
# comments, which can be used inside the partial.
#
# Example:
#
# json.partial! 'comments/comments', comments: @message.comments
#
# There are multiple ways to generate a collection of elements as JSON, as ilustrated below:
#
# Example:
#
# json.array! @posts, partial: 'posts/post', as: :post
#
# # or:
# json.partial! 'posts/post', collection: @posts, as: :post
#
# # or:
# json.partial! partial: 'posts/post', collection: @posts, as: :post
#
# # or:
# json.comments @post.comments, partial: 'comments/comment', as: :comment
#
# Aside from that, the `:cached` options is available on Rails >= 6.0. This will cache the rendered results
# effectively using the multi fetch feature.
#
# Example:
#
# json.array! @posts, partial: "posts/post", as: :post, cached: true
#
# json.comments @post.comments, partial: "comments/comment", as: :comment, cached: true
#
def partial!(*args)
if args.one? && _is_active_model?(args.first)
_render_active_model_partial args.first
Expand Down Expand Up @@ -104,11 +137,30 @@ def set!(name, object = BLANK, *args)
private

def _render_partial_with_options(options)
options.reverse_merge! locals: options.except(:partial, :as, :collection)
options.reverse_merge! locals: options.except(:partial, :as, :collection, :cached)
options.reverse_merge! ::JbuilderTemplate.template_lookup_options
as = options[:as]

if as && options.key?(:collection)
if options.key?(:collection) && (options[:collection].nil? || options[:collection].empty?)
array!
elsif as && options.key?(:collection) && CollectionRenderer.supported?
collection = options.delete(:collection) || []
partial = options.delete(:partial)
options[:locals].merge!(json: self)

if options.has_key?(:layout)
raise ::NotImplementedError, "The `:layout' option is not supported in collection rendering."
end

if options.has_key?(:spacer_template)
raise ::NotImplementedError, "The `:spacer_template' option is not supported in collection rendering."
end

CollectionRenderer
.new(@context.lookup_context, options) { |&block| _scope(&block) }
.render_collection_with_partial(collection, partial, @context, nil)
elsif as && options.key?(:collection) && !CollectionRenderer.supported?
# For Rails <= 5.2:
as = as.to_sym
collection = options.delete(:collection)
locals = options.delete(:locals)
Expand Down
55 changes: 55 additions & 0 deletions test/jbuilder_template_test.rb
Expand Up @@ -283,6 +283,58 @@ class JbuilderTemplateTest < ActiveSupport::TestCase
assert_equal "David", result["firstName"]
end

if JbuilderTemplate::CollectionRenderer.supported?
test "returns an empty array for an empty collection" do
result = render('json.array! @posts, partial: "post", as: :post, cached: true', posts: [])

# Do not use #assert_empty as it is important to ensure that the type of the JSON result is an array.
assert_equal [], result
end

test "supports the cached: true option" do
result = render('json.array! @posts, partial: "post", as: :post, cached: true', posts: POSTS)

assert_equal 10, result.count
assert_equal "Post #5", result[4]["body"]
assert_equal "Heinemeier Hansson", result[2]["author"]["last_name"]
assert_equal "Pavel", result[5]["author"]["first_name"]

expected = {
"id" => 1,
"body" => "Post #1",
"author" => {
"first_name" => "David",
"last_name" => "Heinemeier Hansson"
}
}

assert_equal expected, Rails.cache.read("post-1")

result = render('json.array! @posts, partial: "post", as: :post, cached: true', posts: POSTS)

assert_equal 10, result.count
assert_equal "Post #5", result[4]["body"]
assert_equal "Heinemeier Hansson", result[2]["author"]["last_name"]
assert_equal "Pavel", result[5]["author"]["first_name"]
end

test "raises an error on a render call with the :layout option" do
error = assert_raises NotImplementedError do
render('json.array! @posts, partial: "post", as: :post, layout: "layout"', posts: POSTS)
end

assert_equal "The `:layout' option is not supported in collection rendering.", error.message
end

test "raises an error on a render call with the :spacer_template option" do
error = assert_raises NotImplementedError do
render('json.array! @posts, partial: "post", as: :post, spacer_template: "template"', posts: POSTS)
end

assert_equal "The `:spacer_template' option is not supported in collection rendering.", error.message
end
end

private
def render(*args)
JSON.load render_without_parsing(*args)
Expand All @@ -306,6 +358,9 @@ def build_view(options = {})
end

def view.view_cache_dependencies; []; end
def view.combined_fragment_cache_key(key) [ key ] end
def view.cache_fragment_name(key, *) key end
def view.fragment_name_with_digest(key) key end

view
end
Expand Down
10 changes: 7 additions & 3 deletions test/test_helper.rb
Expand Up @@ -21,13 +21,17 @@ def cache
end
end

class Post < Struct.new(:id, :body, :author_name); end
Jbuilder::CollectionRenderer.collection_cache = Rails.cache

class Post < Struct.new(:id, :body, :author_name)
def cache_key
"post-#{id}"
end
end

class Racer < Struct.new(:id, :name)
extend ActiveModel::Naming
include ActiveModel::Conversion
end

ActionView::Template.register_template_handler :jbuilder, JbuilderHandler

ActionView::Base.remove_possible_method :cache_fragment_name

0 comments on commit d84c37d

Please sign in to comment.