jbuilder 2.9.0 → 2.11.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,5 @@
1
1
  require 'jbuilder/jbuilder'
2
+ require 'jbuilder/collection_renderer'
2
3
  require 'action_dispatch/http/mime_type'
3
4
  require 'active_support/cache'
4
5
 
@@ -15,6 +16,38 @@ class JbuilderTemplate < Jbuilder
15
16
  super(*args)
16
17
  end
17
18
 
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
+ #
18
51
  def partial!(*args)
19
52
  if args.one? && _is_active_model?(args.first)
20
53
  _render_active_model_partial args.first
@@ -73,8 +106,8 @@ class JbuilderTemplate < Jbuilder
73
106
  # json.cache_if! !admin?, @person, expires_in: 10.minutes do
74
107
  # json.extract! @person, :name, :age
75
108
  # end
76
- def cache_if!(condition, *args)
77
- condition ? cache!(*args, &::Proc.new) : yield
109
+ def cache_if!(condition, *args, &block)
110
+ condition ? cache!(*args, &block) : yield
78
111
  end
79
112
 
80
113
  def target!
@@ -104,11 +137,30 @@ class JbuilderTemplate < Jbuilder
104
137
  private
105
138
 
106
139
  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)
108
141
  options.reverse_merge! ::JbuilderTemplate.template_lookup_options
109
142
  as = options[:as]
110
143
 
111
- if as && options.key?(:collection)
144
+ if as && options.key?(:collection) && CollectionRenderer.supported?
145
+ collection = options.delete(:collection) || []
146
+ partial = options.delete(:partial)
147
+ options[:locals].merge!(json: self)
148
+
149
+ if options.has_key?(:layout)
150
+ raise ::NotImplementedError, "The `:layout' option is not supported in collection rendering."
151
+ end
152
+
153
+ if options.has_key?(:spacer_template)
154
+ raise ::NotImplementedError, "The `:spacer_template' option is not supported in collection rendering."
155
+ end
156
+
157
+ results = CollectionRenderer
158
+ .new(@context.lookup_context, options) { |&block| _scope(&block) }
159
+ .render_collection_with_partial(collection, partial, @context, nil)
160
+
161
+ array! if results.respond_to?(:body) && results.body.nil?
162
+ elsif as && options.key?(:collection) && !CollectionRenderer.supported?
163
+ # For Rails <= 5.2:
112
164
  as = as.to_sym
113
165
  collection = options.delete(:collection)
114
166
  locals = options.delete(:locals)
@@ -162,12 +214,7 @@ class JbuilderTemplate < Jbuilder
162
214
 
163
215
  def _fragment_name_with_digest(key, options)
164
216
  if @context.respond_to?(:cache_fragment_name)
165
- # Current compatibility, fragment_name_with_digest is private again and cache_fragment_name
166
- # should be used instead.
167
- @context.cache_fragment_name(key, options)
168
- elsif @context.respond_to?(:fragment_name_with_digest)
169
- # Backwards compatibility for period of time when fragment_name_with_digest was made public.
170
- @context.fragment_name_with_digest(key)
217
+ @context.cache_fragment_name(key, **options)
171
218
  else
172
219
  key
173
220
  end
@@ -1,4 +1,4 @@
1
- require 'rails/railtie'
1
+ require 'rails'
2
2
  require 'jbuilder/jbuilder_template'
3
3
 
4
4
  class Jbuilder
data/lib/jbuilder.rb CHANGED
@@ -1,19 +1,23 @@
1
+ require 'active_support'
1
2
  require 'jbuilder/jbuilder'
2
3
  require 'jbuilder/blank'
3
4
  require 'jbuilder/key_formatter'
4
5
  require 'jbuilder/errors'
5
6
  require 'json'
6
7
  require 'ostruct'
8
+ require 'active_support/core_ext/hash/deep_merge'
7
9
 
8
10
  class Jbuilder
9
11
  @@key_formatter = nil
10
12
  @@ignore_nil = false
13
+ @@deep_format_keys = false
11
14
 
12
15
  def initialize(options = {})
13
16
  @attributes = {}
14
17
 
15
18
  @key_formatter = options.fetch(:key_formatter){ @@key_formatter ? @@key_formatter.clone : nil}
16
19
  @ignore_nil = options.fetch(:ignore_nil, @@ignore_nil)
20
+ @deep_format_keys = options.fetch(:deep_format_keys, @@deep_format_keys)
17
21
 
18
22
  yield self if ::Kernel.block_given?
19
23
  end
@@ -26,12 +30,12 @@ class Jbuilder
26
30
  BLANK = Blank.new
27
31
  NON_ENUMERABLES = [ ::Struct, ::OpenStruct ].to_set
28
32
 
29
- def set!(key, value = BLANK, *args)
33
+ def set!(key, value = BLANK, *args, &block)
30
34
  result = if ::Kernel.block_given?
31
35
  if !_blank?(value)
32
36
  # json.comments @post.comments { |comment| ... }
33
37
  # { "comments": [ { ... }, { ... } ] }
34
- _scope{ array! value, &::Proc.new }
38
+ _scope{ array! value, &block }
35
39
  else
36
40
  # json.comments { ... }
37
41
  # { "comments": ... }
@@ -42,11 +46,11 @@ class Jbuilder
42
46
  # json.age 32
43
47
  # json.person another_jbuilder
44
48
  # { "age": 32, "person": { ... }
45
- value.attributes!
49
+ _format_keys(value.attributes!)
46
50
  else
47
51
  # json.age 32
48
52
  # { "age": 32 }
49
- value
53
+ _format_keys(value)
50
54
  end
51
55
  elsif _is_collection?(value)
52
56
  # json.comments @post.comments, :content, :created_at
@@ -61,9 +65,9 @@ class Jbuilder
61
65
  _set_value key, result
62
66
  end
63
67
 
64
- def method_missing(*args)
68
+ def method_missing(*args, &block)
65
69
  if ::Kernel.block_given?
66
- set!(*args, &::Proc.new)
70
+ set!(*args, &block)
67
71
  else
68
72
  set!(*args)
69
73
  end
@@ -130,6 +134,31 @@ class Jbuilder
130
134
  @@ignore_nil = value
131
135
  end
132
136
 
137
+ # Deeply apply key format to nested hashes and arrays passed to
138
+ # methods like set!, merge! or array!.
139
+ #
140
+ # Example:
141
+ #
142
+ # json.key_format! camelize: :lower
143
+ # json.settings({some_value: "abc"})
144
+ #
145
+ # { "settings": { "some_value": "abc" }}
146
+ #
147
+ # json.key_format! camelize: :lower
148
+ # json.deep_format_keys!
149
+ # json.settings({some_value: "abc"})
150
+ #
151
+ # { "settings": { "someValue": "abc" }}
152
+ #
153
+ def deep_format_keys!(value = true)
154
+ @deep_format_keys = value
155
+ end
156
+
157
+ # Same as instance method deep_format_keys! except sets the default.
158
+ def self.deep_format_keys(value = true)
159
+ @@deep_format_keys = value
160
+ end
161
+
133
162
  # Turns the current element into an array and yields a builder to add a hash.
134
163
  #
135
164
  # Example:
@@ -163,7 +192,7 @@ class Jbuilder
163
192
  #
164
193
  # [ { "name": David", "age": 32 }, { "name": Jamie", "age": 31 } ]
165
194
  #
166
- # If you are using Ruby 1.9+, you can use the call syntax instead of an explicit extract! call:
195
+ # You can use the call syntax instead of an explicit extract! call:
167
196
  #
168
197
  # json.(@people) { |person| ... }
169
198
  #
@@ -181,18 +210,18 @@ class Jbuilder
181
210
  # json.array! [1, 2, 3]
182
211
  #
183
212
  # [1,2,3]
184
- def array!(collection = [], *attributes)
213
+ def array!(collection = [], *attributes, &block)
185
214
  array = if collection.nil?
186
215
  []
187
216
  elsif ::Kernel.block_given?
188
- _map_collection(collection, &::Proc.new)
217
+ _map_collection(collection, &block)
189
218
  elsif attributes.any?
190
219
  _map_collection(collection) { |element| extract! element, *attributes }
191
220
  else
192
- collection.to_a
221
+ _format_keys(collection.to_a)
193
222
  end
194
223
 
195
- merge! array
224
+ @attributes = _merge_values(@attributes, array)
196
225
  end
197
226
 
198
227
  # Extracts the mentioned attributes or hash elements from the passed object and turns them into attributes of the JSON.
@@ -220,9 +249,9 @@ class Jbuilder
220
249
  end
221
250
  end
222
251
 
223
- def call(object, *attributes)
252
+ def call(object, *attributes, &block)
224
253
  if ::Kernel.block_given?
225
- array! object, &::Proc.new
254
+ array! object, &block
226
255
  else
227
256
  extract! object, *attributes
228
257
  end
@@ -240,24 +269,25 @@ class Jbuilder
240
269
  @attributes
241
270
  end
242
271
 
243
- # Merges hash or array into current builder.
244
- def merge!(hash_or_array)
245
- @attributes = _merge_values(@attributes, hash_or_array)
272
+ # Merges hash, array, or Jbuilder instance into current builder.
273
+ def merge!(object)
274
+ hash_or_array = ::Jbuilder === object ? object.attributes! : object
275
+ @attributes = _merge_values(@attributes, _format_keys(hash_or_array))
246
276
  end
247
277
 
248
278
  # Encodes the current builder as JSON.
249
279
  def target!
250
- ::JSON.dump(@attributes)
280
+ @attributes.to_json
251
281
  end
252
282
 
253
283
  private
254
284
 
255
285
  def _extract_hash_values(object, attributes)
256
- attributes.each{ |key| _set_value key, object.fetch(key) }
286
+ attributes.each{ |key| _set_value key, _format_keys(object.fetch(key)) }
257
287
  end
258
288
 
259
289
  def _extract_method_values(object, attributes)
260
- attributes.each{ |key| _set_value key, object.public_send(key) }
290
+ attributes.each{ |key| _set_value key, _format_keys(object.public_send(key)) }
261
291
  end
262
292
 
263
293
  def _merge_block(key)
@@ -275,7 +305,7 @@ class Jbuilder
275
305
  elsif ::Array === current_value && ::Array === updates
276
306
  current_value + updates
277
307
  elsif ::Hash === current_value && ::Hash === updates
278
- current_value.merge(updates)
308
+ current_value.deep_merge(updates)
279
309
  else
280
310
  raise MergeError.build(current_value, updates)
281
311
  end
@@ -285,6 +315,18 @@ class Jbuilder
285
315
  @key_formatter ? @key_formatter.format(key) : key.to_s
286
316
  end
287
317
 
318
+ def _format_keys(hash_or_array)
319
+ return hash_or_array unless @deep_format_keys
320
+
321
+ if ::Array === hash_or_array
322
+ hash_or_array.map { |value| _format_keys(value) }
323
+ elsif ::Hash === hash_or_array
324
+ ::Hash[hash_or_array.collect { |k, v| [_key(k), _format_keys(v)] }]
325
+ else
326
+ hash_or_array
327
+ end
328
+ end
329
+
288
330
  def _set_value(key, value)
289
331
  raise NullError.build(key) if @attributes.nil?
290
332
  raise ArrayError.build(key) if ::Array === @attributes
@@ -300,12 +342,12 @@ class Jbuilder
300
342
  end
301
343
 
302
344
  def _scope
303
- parent_attributes, parent_formatter = @attributes, @key_formatter
345
+ parent_attributes, parent_formatter, parent_deep_format_keys = @attributes, @key_formatter, @deep_format_keys
304
346
  @attributes = BLANK
305
347
  yield
306
348
  @attributes
307
349
  ensure
308
- @attributes, @key_formatter = parent_attributes, parent_formatter
350
+ @attributes, @key_formatter, @deep_format_keys = parent_attributes, parent_formatter, parent_deep_format_keys
309
351
  end
310
352
 
311
353
  def _is_collection?(object)
@@ -61,7 +61,7 @@ class JbuilderDependencyTrackerTest < ActiveSupport::TestCase
61
61
  assert_equal %w[comments/comment], dependencies
62
62
  end
63
63
 
64
- test 'detects explicit depedency' do
64
+ test 'detects explicit dependency' do
65
65
  dependencies = track_dependencies <<-RUBY
66
66
  # Template Dependency: path/to/partial
67
67
  json.foo 'bar'
@@ -43,4 +43,16 @@ class JbuilderGeneratorTest < Rails::Generators::TestCase
43
43
  assert_no_match %r{:created_at, :updated_at}, content
44
44
  end
45
45
  end
46
+
47
+ if Rails::VERSION::MAJOR >= 6
48
+ test 'handles virtual attributes' do
49
+ run_generator %w(Message content:rich_text video:attachment photos:attachments)
50
+
51
+ assert_file 'app/views/messages/_message.json.jbuilder' do |content|
52
+ assert_match %r{json\.content message\.content\.to_s}, content
53
+ assert_match %r{json\.video url_for\(message\.video\)}, content
54
+ assert_match %r{json\.photos do\n json\.array!\(message\.photos\) do \|photo\|\n json\.id photo\.id\n json\.url url_for\(photo\)\n end\nend}, content
55
+ end
56
+ end
57
+ end
46
58
  end
@@ -159,7 +159,7 @@ class JbuilderTemplateTest < ActiveSupport::TestCase
159
159
  end
160
160
 
161
161
  test "object fragment caching with expiry" do
162
- travel_to "2018-05-12 11:29:00 -0400"
162
+ travel_to Time.iso8601("2018-05-12T11:29:00-04:00")
163
163
 
164
164
  render <<-JBUILDER
165
165
  json.cache! "cache-key", expires_in: 1.minute do
@@ -283,6 +283,101 @@ class JbuilderTemplateTest < ActiveSupport::TestCase
283
283
  assert_equal "David", result["firstName"]
284
284
  end
285
285
 
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 "works with an enumerable object" do
295
+ enumerable_class = Class.new do
296
+ include Enumerable
297
+ alias length count # Rails 6.1 requires this.
298
+
299
+ def each(&block)
300
+ [].each(&block)
301
+ end
302
+ end
303
+
304
+ result = render('json.array! @posts, partial: "post", as: :post, cached: true', posts: enumerable_class.new)
305
+
306
+ # Do not use #assert_empty as it is important to ensure that the type of the JSON result is an array.
307
+ assert_equal [], result
308
+ end
309
+
310
+ test "supports the cached: true option" do
311
+ result = render('json.array! @posts, partial: "post", as: :post, cached: true', posts: POSTS)
312
+
313
+ assert_equal 10, result.count
314
+ assert_equal "Post #5", result[4]["body"]
315
+ assert_equal "Heinemeier Hansson", result[2]["author"]["last_name"]
316
+ assert_equal "Pavel", result[5]["author"]["first_name"]
317
+
318
+ expected = {
319
+ "id" => 1,
320
+ "body" => "Post #1",
321
+ "author" => {
322
+ "first_name" => "David",
323
+ "last_name" => "Heinemeier Hansson"
324
+ }
325
+ }
326
+
327
+ assert_equal expected, Rails.cache.read("post-1")
328
+
329
+ result = render('json.array! @posts, partial: "post", as: :post, cached: true', posts: POSTS)
330
+
331
+ assert_equal 10, result.count
332
+ assert_equal "Post #5", result[4]["body"]
333
+ assert_equal "Heinemeier Hansson", result[2]["author"]["last_name"]
334
+ assert_equal "Pavel", result[5]["author"]["first_name"]
335
+ end
336
+
337
+ test "supports the cached: ->() {} option" do
338
+ result = render('json.array! @posts, partial: "post", as: :post, cached: ->(post) { [post, "foo"] }', posts: POSTS)
339
+
340
+ assert_equal 10, result.count
341
+ assert_equal "Post #5", result[4]["body"]
342
+ assert_equal "Heinemeier Hansson", result[2]["author"]["last_name"]
343
+ assert_equal "Pavel", result[5]["author"]["first_name"]
344
+
345
+ expected = {
346
+ "id" => 1,
347
+ "body" => "Post #1",
348
+ "author" => {
349
+ "first_name" => "David",
350
+ "last_name" => "Heinemeier Hansson"
351
+ }
352
+ }
353
+
354
+ assert_equal expected, Rails.cache.read("post-1/foo")
355
+
356
+ result = render('json.array! @posts, partial: "post", as: :post, cached: ->(post) { [post, "foo"] }', posts: POSTS)
357
+
358
+ assert_equal 10, result.count
359
+ assert_equal "Post #5", result[4]["body"]
360
+ assert_equal "Heinemeier Hansson", result[2]["author"]["last_name"]
361
+ assert_equal "Pavel", result[5]["author"]["first_name"]
362
+ end
363
+
364
+ test "raises an error on a render call with the :layout option" do
365
+ error = assert_raises NotImplementedError do
366
+ render('json.array! @posts, partial: "post", as: :post, layout: "layout"', posts: POSTS)
367
+ end
368
+
369
+ assert_equal "The `:layout' option is not supported in collection rendering.", error.message
370
+ end
371
+
372
+ test "raises an error on a render call with the :spacer_template option" do
373
+ error = assert_raises NotImplementedError do
374
+ render('json.array! @posts, partial: "post", as: :post, spacer_template: "template"', posts: POSTS)
375
+ end
376
+
377
+ assert_equal "The `:spacer_template' option is not supported in collection rendering.", error.message
378
+ end
379
+ end
380
+
286
381
  private
287
382
  def render(*args)
288
383
  JSON.load render_without_parsing(*args)
@@ -290,7 +385,7 @@ class JbuilderTemplateTest < ActiveSupport::TestCase
290
385
 
291
386
  def render_without_parsing(source, assigns = {})
292
387
  view = build_view(fixtures: PARTIALS.merge("source.json.jbuilder" => source), assigns: assigns)
293
- view.render(template: "source.json.jbuilder")
388
+ view.render(template: "source")
294
389
  end
295
390
 
296
391
  def build_view(options = {})
@@ -306,6 +401,9 @@ class JbuilderTemplateTest < ActiveSupport::TestCase
306
401
  end
307
402
 
308
403
  def view.view_cache_dependencies; []; end
404
+ def view.combined_fragment_cache_key(key) [ key ] end
405
+ def view.cache_fragment_name(key, *) key end
406
+ def view.fragment_name_with_digest(key) key end
309
407
 
310
408
  view
311
409
  end