Skip to content

Commit d84c37d

Browse files
authored
Speed up collection rendering and support collection caching (#501)
1 parent 606747f commit d84c37d

File tree

5 files changed

+241
-12
lines changed

5 files changed

+241
-12
lines changed

README.md

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -188,19 +188,19 @@ It's also possible to render collections of partials:
188188
json.array! @posts, partial: 'posts/post', as: :post
189189

190190
# or
191-
192191
json.partial! 'posts/post', collection: @posts, as: :post
193192

194193
# or
195-
196194
json.partial! partial: 'posts/post', collection: @posts, as: :post
197195

198196
# or
199-
200197
json.comments @post.comments, partial: 'comments/comment', as: :comment
201198
```
202199

203-
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`.
200+
The `as: :some_symbol` is used with partials. It will take care of mapping the passed in object to a variable for the
201+
partial. If the value is a collection (either implicitly or explicitly by using the `collection:` option, then each
202+
value of the collection is passed to the partial as the variable `some_symbol`. If the value is a singular object,
203+
then the object is passed to the partial as the variable `some_symbol`.
204204

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

@@ -253,6 +253,8 @@ json.bar "bar"
253253
# => { "bar": "bar" }
254254
```
255255

256+
## Caching
257+
256258
Fragment caching is supported, it uses `Rails.cache` and works like caching in
257259
HTML templates:
258260

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

273-
If you are rendering fragments for a collection of objects, have a look at
274-
`jbuilder_cache_multi` gem. It uses fetch_multi (>= Rails 4.1) to fetch
275-
multiple keys at once.
275+
Aside from that, the `:cached` options on collection rendering is available on Rails >= 6.0. This will cache the
276+
rendered results effectively using the multi fetch feature.
277+
278+
```
279+
json.array! @posts, partial: "posts/post", as: :post, cached: true
280+
281+
# or:
282+
json.comments @post.comments, partial: "comments/comment", as: :comment, cached: true
283+
```
284+
285+
## Formatting Keys
276286

277287
Keys can be auto formatted using `key_format!`, this can be used to convert
278288
keynames from the standard ruby_format to camelCase:
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
require 'delegate'
2+
require 'active_support/concern'
3+
4+
begin
5+
require 'action_view/renderer/collection_renderer'
6+
rescue LoadError
7+
require 'action_view/renderer/partial_renderer'
8+
end
9+
10+
class Jbuilder
11+
module CollectionRenderable # :nodoc:
12+
extend ActiveSupport::Concern
13+
14+
class_methods do
15+
def supported?
16+
superclass.private_method_defined?(:build_rendered_template) && self.superclass.private_method_defined?(:build_rendered_collection)
17+
end
18+
end
19+
20+
private
21+
22+
def build_rendered_template(content, template, layout = nil)
23+
super(content || json.attributes!, template)
24+
end
25+
26+
def build_rendered_collection(templates, _spacer)
27+
json.merge!(templates.map(&:body))
28+
end
29+
30+
def json
31+
@options[:locals].fetch(:json)
32+
end
33+
34+
class ScopedIterator < ::SimpleDelegator # :nodoc:
35+
include Enumerable
36+
37+
def initialize(obj, scope)
38+
super(obj)
39+
@scope = scope
40+
end
41+
42+
# Rails 6.0 support:
43+
def each
44+
return enum_for(:each) unless block_given?
45+
46+
__getobj__.each do |object|
47+
@scope.call { yield(object) }
48+
end
49+
end
50+
51+
# Rails 6.1 support:
52+
def each_with_info
53+
return enum_for(:each_with_info) unless block_given?
54+
55+
__getobj__.each_with_info do |object, info|
56+
@scope.call { yield(object, info) }
57+
end
58+
end
59+
end
60+
61+
private_constant :ScopedIterator
62+
end
63+
64+
if defined?(::ActionView::CollectionRenderer)
65+
# Rails 6.1 support:
66+
class CollectionRenderer < ::ActionView::CollectionRenderer # :nodoc:
67+
include CollectionRenderable
68+
69+
def initialize(lookup_context, options, &scope)
70+
super(lookup_context, options)
71+
@scope = scope
72+
end
73+
74+
private
75+
def collection_with_template(view, template, layout, collection)
76+
super(view, template, layout, ScopedIterator.new(collection, @scope))
77+
end
78+
end
79+
else
80+
# Rails 6.0 support:
81+
class CollectionRenderer < ::ActionView::PartialRenderer # :nodoc:
82+
include CollectionRenderable
83+
84+
def initialize(lookup_context, options, &scope)
85+
super(lookup_context)
86+
@options = options
87+
@scope = scope
88+
end
89+
90+
def render_collection_with_partial(collection, partial, context, block)
91+
render(context, @options.merge(collection: collection, partial: partial), block)
92+
end
93+
94+
private
95+
def collection_without_template(view)
96+
@collection = ScopedIterator.new(@collection, @scope)
97+
98+
super(view)
99+
end
100+
101+
def collection_with_template(view, template)
102+
@collection = ScopedIterator.new(@collection, @scope)
103+
104+
super(view, template)
105+
end
106+
end
107+
end
108+
end

lib/jbuilder/jbuilder_template.rb

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
require 'jbuilder/jbuilder'
2+
require 'jbuilder/collection_renderer'
23
require 'action_dispatch/http/mime_type'
34
require 'active_support/cache'
45

@@ -15,6 +16,38 @@ def initialize(context, *args)
1516
super(*args)
1617
end
1718

19+
# Generates JSON using the template specified with the `:partial` option. For example, the code below will render
20+
# the file `views/comments/_comments.json.jbuilder`, and set a local variable comments with all this message's
21+
# comments, which can be used inside the partial.
22+
#
23+
# Example:
24+
#
25+
# json.partial! 'comments/comments', comments: @message.comments
26+
#
27+
# There are multiple ways to generate a collection of elements as JSON, as ilustrated below:
28+
#
29+
# Example:
30+
#
31+
# json.array! @posts, partial: 'posts/post', as: :post
32+
#
33+
# # or:
34+
# json.partial! 'posts/post', collection: @posts, as: :post
35+
#
36+
# # or:
37+
# json.partial! partial: 'posts/post', collection: @posts, as: :post
38+
#
39+
# # or:
40+
# json.comments @post.comments, partial: 'comments/comment', as: :comment
41+
#
42+
# Aside from that, the `:cached` options is available on Rails >= 6.0. This will cache the rendered results
43+
# effectively using the multi fetch feature.
44+
#
45+
# Example:
46+
#
47+
# json.array! @posts, partial: "posts/post", as: :post, cached: true
48+
#
49+
# json.comments @post.comments, partial: "comments/comment", as: :comment, cached: true
50+
#
1851
def partial!(*args)
1952
if args.one? && _is_active_model?(args.first)
2053
_render_active_model_partial args.first
@@ -104,11 +137,30 @@ def set!(name, object = BLANK, *args)
104137
private
105138

106139
def _render_partial_with_options(options)
107-
options.reverse_merge! locals: options.except(:partial, :as, :collection)
140+
options.reverse_merge! locals: options.except(:partial, :as, :collection, :cached)
108141
options.reverse_merge! ::JbuilderTemplate.template_lookup_options
109142
as = options[:as]
110143

111-
if as && options.key?(:collection)
144+
if options.key?(:collection) && (options[:collection].nil? || options[:collection].empty?)
145+
array!
146+
elsif as && options.key?(:collection) && CollectionRenderer.supported?
147+
collection = options.delete(:collection) || []
148+
partial = options.delete(:partial)
149+
options[:locals].merge!(json: self)
150+
151+
if options.has_key?(:layout)
152+
raise ::NotImplementedError, "The `:layout' option is not supported in collection rendering."
153+
end
154+
155+
if options.has_key?(:spacer_template)
156+
raise ::NotImplementedError, "The `:spacer_template' option is not supported in collection rendering."
157+
end
158+
159+
CollectionRenderer
160+
.new(@context.lookup_context, options) { |&block| _scope(&block) }
161+
.render_collection_with_partial(collection, partial, @context, nil)
162+
elsif as && options.key?(:collection) && !CollectionRenderer.supported?
163+
# For Rails <= 5.2:
112164
as = as.to_sym
113165
collection = options.delete(:collection)
114166
locals = options.delete(:locals)

test/jbuilder_template_test.rb

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,58 @@ class JbuilderTemplateTest < ActiveSupport::TestCase
283283
assert_equal "David", result["firstName"]
284284
end
285285

286+
if JbuilderTemplate::CollectionRenderer.supported?
287+
test "returns an empty array for an empty collection" do
288+
result = render('json.array! @posts, partial: "post", as: :post, cached: true', posts: [])
289+
290+
# Do not use #assert_empty as it is important to ensure that the type of the JSON result is an array.
291+
assert_equal [], result
292+
end
293+
294+
test "supports the cached: true option" do
295+
result = render('json.array! @posts, partial: "post", as: :post, cached: true', posts: POSTS)
296+
297+
assert_equal 10, result.count
298+
assert_equal "Post #5", result[4]["body"]
299+
assert_equal "Heinemeier Hansson", result[2]["author"]["last_name"]
300+
assert_equal "Pavel", result[5]["author"]["first_name"]
301+
302+
expected = {
303+
"id" => 1,
304+
"body" => "Post #1",
305+
"author" => {
306+
"first_name" => "David",
307+
"last_name" => "Heinemeier Hansson"
308+
}
309+
}
310+
311+
assert_equal expected, Rails.cache.read("post-1")
312+
313+
result = render('json.array! @posts, partial: "post", as: :post, cached: true', posts: POSTS)
314+
315+
assert_equal 10, result.count
316+
assert_equal "Post #5", result[4]["body"]
317+
assert_equal "Heinemeier Hansson", result[2]["author"]["last_name"]
318+
assert_equal "Pavel", result[5]["author"]["first_name"]
319+
end
320+
321+
test "raises an error on a render call with the :layout option" do
322+
error = assert_raises NotImplementedError do
323+
render('json.array! @posts, partial: "post", as: :post, layout: "layout"', posts: POSTS)
324+
end
325+
326+
assert_equal "The `:layout' option is not supported in collection rendering.", error.message
327+
end
328+
329+
test "raises an error on a render call with the :spacer_template option" do
330+
error = assert_raises NotImplementedError do
331+
render('json.array! @posts, partial: "post", as: :post, spacer_template: "template"', posts: POSTS)
332+
end
333+
334+
assert_equal "The `:spacer_template' option is not supported in collection rendering.", error.message
335+
end
336+
end
337+
286338
private
287339
def render(*args)
288340
JSON.load render_without_parsing(*args)
@@ -306,6 +358,9 @@ def build_view(options = {})
306358
end
307359

308360
def view.view_cache_dependencies; []; end
361+
def view.combined_fragment_cache_key(key) [ key ] end
362+
def view.cache_fragment_name(key, *) key end
363+
def view.fragment_name_with_digest(key) key end
309364

310365
view
311366
end

test/test_helper.rb

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,17 @@ def cache
2121
end
2222
end
2323

24-
class Post < Struct.new(:id, :body, :author_name); end
24+
Jbuilder::CollectionRenderer.collection_cache = Rails.cache
25+
26+
class Post < Struct.new(:id, :body, :author_name)
27+
def cache_key
28+
"post-#{id}"
29+
end
30+
end
2531

2632
class Racer < Struct.new(:id, :name)
2733
extend ActiveModel::Naming
2834
include ActiveModel::Conversion
2935
end
3036

3137
ActionView::Template.register_template_handler :jbuilder, JbuilderHandler
32-
33-
ActionView::Base.remove_possible_method :cache_fragment_name

0 commit comments

Comments
 (0)