Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Speed up collection rendering and add support for multifetch collection handling #501

Merged
merged 1 commit into from
Nov 14, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 17 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
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
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do not believe we have to mention it any more as Jbuilder will have it built-in once this PR is merged. So I just replaced it with how cached: true could be used on collection rendering.

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
Original file line number Diff line number Diff line change
@@ -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)
yuki24 marked this conversation as resolved.
Show resolved Hide resolved
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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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