diff --git a/README.md b/README.md index 0fbf1ad..2dabfc7 100644 --- a/README.md +++ b/README.md @@ -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: @@ -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: @@ -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: diff --git a/lib/jbuilder/collection_renderer.rb b/lib/jbuilder/collection_renderer.rb new file mode 100644 index 0000000..d46e903 --- /dev/null +++ b/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 diff --git a/lib/jbuilder/jbuilder_template.rb b/lib/jbuilder/jbuilder_template.rb index f6add19..8a8c970 100644 --- a/lib/jbuilder/jbuilder_template.rb +++ b/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' @@ -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 @@ -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) diff --git a/test/jbuilder_template_test.rb b/test/jbuilder_template_test.rb index b6b6eb8..1de7fd2 100644 --- a/test/jbuilder_template_test.rb +++ b/test/jbuilder_template_test.rb @@ -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) @@ -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 diff --git a/test/test_helper.rb b/test/test_helper.rb index dcac36c..78c86b1 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -21,7 +21,13 @@ 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 @@ -29,5 +35,3 @@ class Racer < Struct.new(:id, :name) end ActionView::Template.register_template_handler :jbuilder, JbuilderHandler - -ActionView::Base.remove_possible_method :cache_fragment_name