rabl 0.17.0 → 0.18.0

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.
data/lib/rabl/engine.rb CHANGED
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Rabl
2
4
  class Engine
3
5
  include Helpers
@@ -8,6 +10,66 @@ module Rabl
8
10
  FORMATS = [:json, :xml, :plist, :bson, :msgpack]
9
11
  SAFE_FORMATS = FORMATS + [:mpac, :dumpable, :hash]
10
12
 
13
+ # JSONP callback names that are accepted: identifiers, optionally dotted (e.g. "jQuery_1.cb")
14
+ RABL_JSON_CALLBACK_NAME = /\A[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*\z/
15
+ RABL_JSON_CALLBACK_MAX_LENGTH = 128
16
+
17
+ # Array#to_xml names its items after the singular of the dasherized root, which misses
18
+ # inflections that match only the underscored name: rules written in underscored form
19
+ # (inflect.irregular 'lesson_progress', 'lessons_progresses', #559), underscored uncountable
20
+ # words (inflect.uncountable 'user_settings') and rules using \w (/^(\w+)ves$/). An XmlArray
21
+ # names them after the singular of the root as given, as rabl does for JSON item roots
22
+ # (to_xml then dasherizes it like any other key), when that singular comes from a newer rule
23
+ # than the one ActiveSupport applies to the dasherized root. Otherwise the name is left to
24
+ # Array#to_xml, so built-in inflections, inflections written with dashes (the workaround in
25
+ # #559) and uncountable words at the end of a name ("animal-species") are named as before.
26
+ # The arrays of one rendering share children_names, so each root's name is worked out once.
27
+ class XmlArray < Array
28
+ def initialize(items, children_names)
29
+ super(items)
30
+ @children_names = children_names
31
+ end
32
+
33
+ def to_xml(options = {}, &block)
34
+ root = options[:root].to_s
35
+ unless options[:children] || !options[:root] || root.empty?
36
+ renamed = ActiveSupport::XmlMini.rename_key(root, options)
37
+ children = @children_names.fetch([root, renamed]) do |key|
38
+ @children_names[key] = children_name(root, renamed, options)
39
+ end
40
+ options = options.merge(:children => children)
41
+ end
42
+ super(options, &block)
43
+ end
44
+
45
+ private
46
+
47
+ # The singular of root when it comes from a newer rule than the one ActiveSupport applies
48
+ # to the renamed root, or else the singular of the renamed root, as Array#to_xml names items
49
+ def children_name(root, renamed, options)
50
+ singular = root.singularize
51
+ renamed_singular = renamed.singularize
52
+ xml_mini = ActiveSupport::XmlMini
53
+ return renamed_singular if xml_mini.rename_key(singular, options) == xml_mini.rename_key(renamed_singular, options)
54
+
55
+ singular_rule_rank(root) < singular_rule_rank(renamed) ? singular : renamed_singular
56
+ end
57
+
58
+ # The position of the singular rule ActiveSupport applies to word. The rules are newest
59
+ # first, so a lower rank is a rule defined later; -1 for an uncountable word, and the rule
60
+ # count when no rule matches.
61
+ def singular_rule_rank(word)
62
+ inflections = ActiveSupport::Inflector.inflections
63
+ uncountables = inflections.uncountables
64
+ # ActiveSupport < 5 keeps them in a plain Array, checked by the last word
65
+ return -1 if uncountables.respond_to?(:uncountable?) ? uncountables.uncountable?(word) : uncountables.include?(word.downcase[/\b\w+\Z/])
66
+
67
+ rules = inflections.singulars
68
+ rules.index { |(rule, _)| rule.is_a?(Regexp) ? rule.match?(word) : word.include?(rule) } || rules.size
69
+ end
70
+ end
71
+ private_constant :XmlArray
72
+
11
73
  # Constructs a new ejs engine based on given vars, handler and declarations
12
74
  # Rabl::Engine.new("...source...", { :format => "xml", :root => true, :view_path => "/path/to/views" })
13
75
  def initialize(source, options = {})
@@ -17,8 +79,11 @@ module Rabl
17
79
 
18
80
  @_view_path = options[:view_path]
19
81
  @_context_scope = options[:scope]
82
+ @_data_item = options[:data_item]
20
83
 
21
84
  @_cache_read_on_render = true
85
+ @_skip_json_callback = false
86
+ @_rendering = false
22
87
  end
23
88
 
24
89
  def source=(source)
@@ -35,6 +100,17 @@ module Rabl
35
100
  reset_settings!
36
101
  reset_options!
37
102
 
103
+ # A block (child, glue, or extends/partial with a block) is identified by its
104
+ # location and the engine of the template that defined it
105
+ @_block_identity = block && [block, @_options[:parent_engine]]
106
+ @_template_identity = nil
107
+ @_identity_memo = nil
108
+
109
+ # A cache key belongs to one render; the template sets it again
110
+ remove_instance_variable(:@_cache_key) if defined?(@_cache_key)
111
+ remove_instance_variable(:@_replaced_data) if defined?(@_replaced_data)
112
+ @_full_cache_key = nil
113
+
38
114
  eval_source(locals, &block)
39
115
 
40
116
  instance_exec(root_object, &block) if block_given?
@@ -47,16 +123,28 @@ module Rabl
47
123
  def render(context_scope = nil, locals = nil, &block)
48
124
  apply(context_scope, locals, &block) if context_scope || locals || block
49
125
 
50
- cache_results do
51
- send("to_#{@_options[:format]}")
126
+ return cache_results { send("to_#{@_options[:format]}") } unless @_options[:format].to_s == "json"
127
+
128
+ # The JSONP callback comes from the request, so it is added after the cache.
129
+ result = cache_results do
130
+ begin
131
+ @_skip_json_callback = true
132
+ to_json
133
+ ensure
134
+ @_skip_json_callback = false
135
+ end
52
136
  end
137
+
138
+ rabl_wrap_json_callback(result)
53
139
  end
54
140
 
55
141
  def cache_key
56
142
  return unless defined?(@_cache_key)
57
143
 
58
144
  @_full_cache_key ||= begin
59
- cache_key = Array(@_cache_key) + [@_options[:root_name], @_options[:format]]
145
+ cache_key = rabl_cache_key_parts + [@_options[:root_name], @_options[:format]]
146
+ # a nested engine caches its output before it is escaped, so it gets its own key
147
+ cache_key << 'nested' if @_options[:nested]
60
148
 
61
149
  if digestor_available? && respond_to?(:lookup_context) && lookup_context
62
150
  template = @_options[:template] || @virtual_path
@@ -71,8 +159,19 @@ module Rabl
71
159
  end
72
160
 
73
161
  cache_key << digest
162
+ # The digest covers the template source, but not the block that defines this engine
163
+ if @_block_identity
164
+ block, parent = @_block_identity
165
+ key = [:block, block, parent && parent.rabl_template_identity]
166
+ cache_key << rabl_memoized_digest(key) { TemplateCacheKey.digest(rabl_block_identity) }
167
+ end
168
+ else
169
+ cache_key << rabl_template_identity
74
170
  end
75
171
 
172
+ cache_key << "collection" if is_collection?(root_object)
173
+ cache_key.concat(TemplateCacheKey.shape(@_options, source_lookup_format))
174
+
76
175
  cache_key
77
176
  end
78
177
  end
@@ -80,12 +179,15 @@ module Rabl
80
179
  # Returns a hash representation of the data object
81
180
  # to_hash(:root => true, :child_root => true)
82
181
  def to_hash(options = {})
83
- options.reverse_merge!(@_options)
182
+ # options given to the engine (e.g. Rabl::Renderer's :root) win over the format defaults
183
+ options.merge!(@_options)
84
184
 
85
185
  data = root_object
86
186
 
87
187
  options[:root_name] = determine_object_root(data, root_name, options[:root])
188
+ options[:template_engine] = self
88
189
 
190
+ @_rendering = true
89
191
  result =
90
192
  if is_object?(data) || !data # object @user
91
193
  Builder.new(data, @_settings, options).to_hash
@@ -93,9 +195,12 @@ module Rabl
93
195
  MultiBuilder.new(data, @_settings, options).to_a
94
196
  end
95
197
 
96
- result = escape_output(result) if Rabl.configuration.escape_all_output
198
+ # A nested engine (child, glue, extends, partial) is escaped with the engine it is part of
199
+ result = escape_output(result) if Rabl.configuration.escape_all_output && !@_options[:nested]
97
200
 
98
201
  result
202
+ ensure
203
+ @_rendering = false
99
204
  end
100
205
 
101
206
  def to_dumpable(options = {})
@@ -111,7 +216,7 @@ module Rabl
111
216
  def to_json(options = {})
112
217
  options.reverse_merge!({ :root => Rabl.configuration.include_json_root })
113
218
  result = to_dumpable(options)
114
- format_json(result)
219
+ rabl_wrap_json_callback(format_json(result))
115
220
  end
116
221
 
117
222
  # Returns a msgpack representation of the data object
@@ -134,6 +239,8 @@ module Rabl
134
239
  # Returns an xml representation of the data object
135
240
  # to_xml(:root => true)
136
241
  def to_xml(options = {})
242
+ load_xml_support!
243
+
137
244
  options = {
138
245
  :root => (include_root = Rabl.configuration.include_xml_root),
139
246
  :child_root => include_root && Rabl.configuration.include_child_root
@@ -143,7 +250,14 @@ module Rabl
143
250
 
144
251
  result = to_hash(options)
145
252
 
146
- result.to_xml(xml_options)
253
+ # Name the items after the object root and unwrap them, or Array#to_xml wraps each
254
+ # { object_root => ... } item again in the singular collection root (#581)
255
+ if result.is_a?(Array) && is_name_value?(object_root_name)
256
+ xml_options[:children] = object_root_name.to_s
257
+ result = result.map { |item| item.is_a?(Hash) && item.key?(object_root_name) ? item[object_root_name] : item }
258
+ end
259
+
260
+ xml_arrays(result).to_xml(xml_options)
147
261
  end
148
262
 
149
263
  # Returns a bson representation of the data object
@@ -156,7 +270,14 @@ module Rabl
156
270
  result = { root_name => result }
157
271
  end
158
272
 
159
- Rabl.configuration.bson_engine.serialize(result).to_s
273
+ bson_engine = Rabl.configuration.bson_engine
274
+ if bson_engine.respond_to?(:serialize)
275
+ # BSON < 2.0 API
276
+ bson_engine.serialize(result).to_s
277
+ else
278
+ # BSON >= 2.0 API
279
+ result.to_bson.to_s
280
+ end
160
281
  end
161
282
 
162
283
  # Sets the object to be used as the data source for this template
@@ -165,9 +286,20 @@ module Rabl
165
286
  # object @users
166
287
  def object(template_data)
167
288
  current_data = (@_locals[:object].nil? || template_data == false) ? template_data : @_locals[:object]
289
+ # Rendered for an item (extends, partial, child), the template gets the item instead of
290
+ # the data it names, e.g. the outer @user; cache keys then add the item (see #rabl_cache_key_parts)
291
+ if !current_data.equal?(template_data)
292
+ alias_form = template_data.is_a?(Hash) && template_data.size == 1 && is_name_value?(template_data.values.first)
293
+ @_replaced_data = alias_form ? template_data.keys.first : template_data # object @user => :person names @user
294
+ end
168
295
  @_data_object = data_object(current_data)
169
296
  @_root_name_data = template_data.is_a?(Hash) && !current_data.is_a?(Hash) ? template_data : current_data
170
- @_root_name_data = @_root_name_data.values.first if @_root_name_data.is_a?(Hash)
297
+ if @_root_name_data.is_a?(Hash)
298
+ alias_form = @_root_name_data.size == 1 # object @user => :person
299
+ @_root_name_data = @_root_name_data.values.first
300
+ # object @user => "person" names the root the same way
301
+ @_root_name_data = @_root_name_data.to_sym if alias_form && @_root_name_data.is_a?(String)
302
+ end
171
303
 
172
304
  # If we turn this around, `@_root_name_date ==` may trigger data to be loaded unnecessarily.
173
305
  # TODO: is nil a different semantic? otherwise don't use `false ==`, use !
@@ -201,13 +333,19 @@ module Rabl
201
333
  # collection @users => :people
202
334
  # collection @users, :root => :person
203
335
  # collection @users, :object_root => :person
336
+ # collection @users => :people, :object_root => false
204
337
  def collection(data, options = {})
338
+ data, options = split_folded_options(data, options, [:root, :object_root])
339
+
205
340
  @_collection_name = options[:root] if options[:root]
206
341
  @_collection_name ||= data.values.first if data.is_a?(Hash)
207
342
 
208
343
  @_object_root_name = options[:object_root] if options.has_key?(:object_root)
209
344
 
210
- object(data_object(data) || [])
345
+ collection_data = data_object(data)
346
+ object(collection_data || [])
347
+ # a nil collection is given to object as [], but the template names nil (see #object)
348
+ @_replaced_data = collection_data if defined?(@_replaced_data)
211
349
  end
212
350
 
213
351
  # Sets the cache key to be used by ActiveSupport::Cache.expand_cache_key
@@ -236,6 +374,8 @@ module Rabl
236
374
  attribute(key, conditions.merge(:as => as))
237
375
  end
238
376
  else # array of attributes i.e :foo, :bar, :baz
377
+ warn_if_rendering(:attribute)
378
+
239
379
  options = args.extract_options!
240
380
  args.each do |name|
241
381
  @_settings[:attributes] << { :name => name, :options => options }
@@ -248,25 +388,42 @@ module Rabl
248
388
  # node(:foo) { "bar" }
249
389
  # node(:foo, :if => lambda { ... }) { "bar" }
250
390
  def node(name = nil, options = {}, &block)
391
+ warn_if_rendering(:node)
392
+
393
+ if name.is_a?(Hash) && name.present? && options.blank? && (name.keys - [:if, :unless]).empty?
394
+ name, options = nil, name # node(:if => lambda { ... }) { ... } has conditions but no name
395
+ end
396
+
251
397
  @_settings[:node] << { :name => name, :options => options, :block => block }
252
398
  end
253
399
  alias_method :code, :node
254
400
 
255
401
  # Creates a child node that is included in json output
256
402
  # child(@user) { attribute :full_name }
403
+ # child(:posts => :items, :if => lambda { ... }) { attribute :title }
257
404
  def child(data, options = {}, &block)
405
+ warn_if_rendering(:child)
406
+
407
+ data, options = split_folded_options(data, options, [:if, :unless, :root, :object_root])
408
+
258
409
  @_settings[:child] << { :data => data, :options => options, :block => block }
259
410
  end
260
411
 
261
412
  # Glues data from a child node to the json_output
262
413
  # glue(@user) { attribute :full_name => :user_full_name }
263
414
  def glue(data, options = {}, &block)
415
+ warn_if_rendering(:glue)
416
+
417
+ data, options = split_folded_options(data, options, [:if, :unless])
418
+
264
419
  @_settings[:glue] << { :data => data, :options => options, :block => block }
265
420
  end
266
421
 
267
422
  # Extends an existing rabl template with additional attributes in the block
268
423
  # extends("users/show", :object => @user) { attribute :full_name }
269
424
  def extends(file, options = {}, &block)
425
+ return if warn_if_rendering(:extends)
426
+
270
427
  options.reverse_merge!({ :view_path => options[:view_path] || view_path })
271
428
 
272
429
  @_settings[:extends] << { :file => file, :options => options, :block => block }
@@ -298,7 +455,81 @@ module Rabl
298
455
  @_cache_read_on_render
299
456
  end
300
457
 
458
+ # Returns the key given to `cache` in the template, if any. When the template is rendered
459
+ # for an item (see #object) and its key has the data the template names (`cache @user`, or
460
+ # the nil in `cache ['v1', @topic]`) but not the item, the item is added, so each item gets
461
+ # its own key; the other parts (such as the current user) are kept.
462
+ # ceiling: only the key's own parts are compared, not those of an array nested in it;
463
+ # walk nested arrays too if such keys turn up.
464
+ def rabl_cache_key_parts
465
+ return [] unless defined?(@_cache_key)
466
+
467
+ parts = Array(@_cache_key)
468
+ return parts unless defined?(@_replaced_data)
469
+
470
+ item = root_object
471
+ return parts if parts.any? { |part| part.equal?(item) } || parts.none? { |part| part.equal?(@_replaced_data) }
472
+
473
+ parts + [item]
474
+ end
475
+
476
+ # Identifies the template for cache keys: a digest of its location, the view path its
477
+ # partials resolve from, its source and its block
478
+ def rabl_template_identity
479
+ @_template_identity ||= begin
480
+ location = @_options[:source_location]
481
+
482
+ if @_options[:parent_engine] && (location || !@_source)
483
+ # Within one render, a template's location determines its source
484
+ # (nested lookups rather than an Array key, as this runs for every item)
485
+ memo = (rabl_identity_memo[location] ||= {})
486
+ memo = (memo[view_path] ||= {})
487
+
488
+ if @_block_identity
489
+ block, parent = @_block_identity
490
+ memo = (memo[block] ||= {})
491
+ memo[parent && parent.rabl_template_identity] ||= rabl_template_digest
492
+ else
493
+ memo[:template] ||= rabl_template_digest
494
+ end
495
+ else
496
+ rabl_template_digest
497
+ end
498
+ end
499
+ end
500
+
301
501
  protected
502
+ def rabl_template_digest
503
+ root = TemplateCacheKey.root
504
+ view_paths = Array(view_path).map { |path| TemplateCacheKey.path(path, root) }
505
+ TemplateCacheKey.digest([TemplateCacheKey.path(@_options[:source_location], root), view_paths, @_source, rabl_block_identity(root)])
506
+ end
507
+
508
+ # Returns the digest for key, computing it once per render: the memo is kept by the
509
+ # top-level engine, so the items of a collection share their sub-templates' digests
510
+ def rabl_memoized_digest(key)
511
+ rabl_identity_memo[key] ||= yield
512
+ end
513
+
514
+ def rabl_identity_memo
515
+ @_identity_memo ||= (parent = @_options[:parent_engine]) ? parent.rabl_identity_memo : {}
516
+ end
517
+
518
+ def rabl_template_engine
519
+ self
520
+ end
521
+
522
+ # Identifies the block that defines this engine, if any: its location and the
523
+ # template that defined it
524
+ def rabl_block_identity(root = TemplateCacheKey.root)
525
+ return unless @_block_identity
526
+
527
+ block, parent = @_block_identity
528
+ location = block.source_location
529
+ location &&= [TemplateCacheKey.path(location[0], root), location[1], location.size > 2 ? location[2] : TemplateCacheKey.column(block)]
530
+ [location, parent && parent.rabl_template_identity]
531
+ end
532
+
302
533
  # Returns a guess at the default object for this template
303
534
  # default_object => @user
304
535
  def default_object
@@ -331,25 +562,43 @@ module Rabl
331
562
  (context_scope.params if context_scope.respond_to?(:params)) || {}
332
563
  end
333
564
 
334
- # Returns data as json embraced with callback when detected
335
- # format_json({ :foo => "bar" }) => "test({ foo : 'bar' })"
336
- # format_json("{ foo : "bar" }") => "test({ foo : 'bar' })"
565
+ # Returns data as json
566
+ # format_json({ :foo => "bar" }) => "{\"foo\":\"bar\"}"
337
567
  def format_json(json_output)
338
568
  unless json_output.is_a?(String)
339
569
  json_engine = Rabl.configuration.json_engine
340
- json_output = if json_engine.respond_to?(:dump)
570
+ json_output = if defined?(::Oj) && json_engine.equal?(::Oj)
571
+ oj_options = Rabl.configuration.oj_dump_options
572
+ oj_options.empty? ? ::Oj.dump(json_output) : ::Oj.dump(json_output, oj_options)
573
+ elsif json_engine.respond_to?(:dump)
341
574
  json_engine.dump(json_output)
342
575
  else
343
576
  json_engine.encode(json_output)
344
577
  end
345
578
  end
346
579
 
347
- use_callback = Rabl.configuration.enable_json_callbacks && request_params[:callback].present?
348
- json_output = "#{request_params[:callback]}(#{json_output})" if use_callback
349
-
350
580
  json_output
351
581
  end
352
582
 
583
+ # Returns json embraced with the request's callback when callbacks are enabled
584
+ # and the callback name is valid, otherwise the json unchanged
585
+ # rabl_wrap_json_callback("{}") => "/**/test({})"
586
+ def rabl_wrap_json_callback(json)
587
+ callback = rabl_json_callback_name unless @_skip_json_callback
588
+ callback ? "/**/#{callback}(#{json})" : json
589
+ end
590
+
591
+ # Returns the request's callback parameter if callbacks are enabled and it is a valid name
592
+ def rabl_json_callback_name
593
+ return unless Rabl.configuration.enable_json_callbacks
594
+
595
+ callback = request_params[:callback]
596
+ return unless callback.is_a?(String) && callback.length <= RABL_JSON_CALLBACK_MAX_LENGTH
597
+ return unless callback.ascii_only? && callback =~ RABL_JSON_CALLBACK_NAME
598
+
599
+ callback
600
+ end
601
+
353
602
  # Augments respond to supporting context_scope methods
354
603
  def respond_to?(name, include_private = false)
355
604
  context_scope.respond_to?(name, include_private) || super
@@ -426,21 +675,88 @@ module Rabl
426
675
  end
427
676
 
428
677
  def eval_source(locals, &block)
429
- return unless @_source.present?
678
+ source = @_source
679
+ # A UTF-8 byte order mark (as some editors save files) isn't part of the template. It is
680
+ # checked by its bytes first, as it is invalid in the encoding the source may be read in
681
+ # (US-ASCII under LANG=C), where reading the source as text raises.
682
+ source = source.byteslice(3..-1) if source.is_a?(String) && source.getbyte(0) == 0xEF && source.getbyte(1) == 0xBB && source.getbyte(2) == 0xBF
683
+ return unless source.present?
430
684
 
431
- msg = "cached_source_#{@_source.hash.abs}"
685
+ # One method per source and location, so blocks report the file that defined them
686
+ line = @_options[:source_line] || 1
687
+ msg = "cached_source_#{[@_source, @_options[:source_location], line].hash.abs}"
432
688
 
433
689
  unless self.respond_to? msg then
434
- src = "def #{msg} locals, &block\n#{@_source}\nend"
690
+ src = "def #{msg} locals, &block\n#{source}\nend"
435
691
 
436
- if @_options[:source_location]
437
- self.class.class_eval(src, @_options[:source_location])
438
- else
439
- self.class.class_eval(src)
440
- end
692
+ # The def line comes before the template's first line, so errors report the
693
+ # template's own line numbers
694
+ self.class.class_eval(src, @_options[:source_location] || "(eval)", line - 1)
441
695
  end
442
696
 
443
697
  send msg, locals, &block
444
698
  end
699
+
700
+ # Hash#to_xml and Array#to_xml come from ActiveSupport (which uses the builder gem);
701
+ # Rails loads them, but other apps may not have.
702
+ def load_xml_support!
703
+ return if defined?(::Builder::XmlMarkup) && {}.respond_to?(:to_xml) && [].respond_to?(:to_xml)
704
+
705
+ require 'active_support/core_ext/array/conversions'
706
+ require 'active_support/core_ext/hash/conversions'
707
+
708
+ begin
709
+ require 'builder' unless defined?(::Builder::XmlMarkup)
710
+ rescue LoadError
711
+ raise LoadError, "rabl needs the builder gem to render XML; add `gem 'builder'` to your Gemfile"
712
+ end
713
+ end
714
+
715
+ # Returns a copy of the data with each Array in it, at any depth, as an XmlArray; arrays
716
+ # with a to_xml of their own are left as they are
717
+ def xml_arrays(data, children_names = {})
718
+ case data
719
+ when Hash
720
+ copy = data.dup
721
+ data.each { |key, value| copy[key] = xml_arrays(value, children_names) }
722
+ copy
723
+ when Array
724
+ return data unless data.method(:to_xml).owner == Array
725
+
726
+ XmlArray.new(data.map { |value| xml_arrays(value, children_names) }, children_names)
727
+ else
728
+ data
729
+ end
730
+ end
731
+
732
+ # attribute, node, child, glue and extends build the template's settings, which are read while
733
+ # it renders (#477). Called then (e.g. inside a node block), they warn; attribute, node, child
734
+ # and glue work as before, and extends is ignored, since its settings (view path included)
735
+ # would otherwise be the node's value. Returns true when the template is rendering.
736
+ # ceiling: warns on every call, so a misuse inside a large collection repeats the warning;
737
+ # dedupe by call site if that turns out to be noisy.
738
+ def warn_if_rendering(method_name)
739
+ return false unless @_rendering
740
+
741
+ effect = method_name == :extends ? "is ignored" : "changes the template for the rest of the render"
742
+ site = caller.find { |line| !line.start_with?(__FILE__) }.to_s.sub(/:in .*\z/m, '')
743
+ Kernel.warn "rabl: #{method_name} called while the template renders (e.g. inside a node block) at #{site} " \
744
+ "#{effect}; to render another template there, use partial(\"template\", :object => object)"
745
+ true
746
+ end
747
+
748
+ # Ruby folds `child :posts => :items, :if => cond` into a single hash, so the options
749
+ # arrive inside the data. They are split out only when no options were given separately
750
+ # and what remains is a single alias (`data => name`); any other hash is left as data.
751
+ # split_folded_options({ :posts => :items, :if => true }, {}, [:if]) => [{ :posts => :items }, { :if => true }]
752
+ def split_folded_options(data, options, option_keys)
753
+ return [data, options] unless data.is_a?(Hash) && options.blank?
754
+
755
+ found = data.keys & option_keys
756
+ alias_data = data.except(*found)
757
+ return [data, options] unless found.any? && alias_data.size == 1 && is_name_value?(alias_data.values.first)
758
+
759
+ [alias_data, data.slice(*found)]
760
+ end
445
761
  end
446
762
  end
data/lib/rabl/helpers.rb CHANGED
@@ -1,19 +1,25 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'active_support/inflector' # for the sake of pluralizing
4
+ require 'erb' # ERB::Util.h for escape_all_output
2
5
 
3
6
  module Rabl
4
7
  module Helpers
5
8
  # data_object(data) => <AR Object>
6
9
  # data_object(@user => :person) => @user
7
- # data_object(:user => :person) => @_object.send(:user)
10
+ # data_object(:user => :person) => @_object.public_send(:user)
8
11
  def data_object(data)
12
+ return data if data_item?(data)
13
+
9
14
  data = data.keys.first if data.is_a?(Hash) && data.keys.size == 1
10
- data = @_object.__send__(data) if data.is_a?(Symbol) && defined?(@_object) && @_object && @_object.respond_to?(data)
15
+ data = @_object.public_send(data) if data.is_a?(Symbol) && defined?(@_object) && @_object && @_object.respond_to?(data)
11
16
  data
12
17
  end
13
18
 
14
- # data_object_attribute(data) => @_object.send(data)
19
+ # data_object_attribute(data) => @_object.public_send(data)
20
+ # public_send, so a name like :format reaches method_missing (Hashie::Mash) instead of private Kernel#format
15
21
  def data_object_attribute(data)
16
- attribute = @_object.__send__(data)
22
+ attribute = @_object.public_send(data)
17
23
  attribute = attribute.as_json if
18
24
  is_collection?(attribute, false) &&
19
25
  attribute.respond_to?(:as_json)
@@ -34,6 +40,7 @@ module Rabl
34
40
 
35
41
  if is_collection?(data) # data is a collection
36
42
  object_name = collection_root_name if collection_root_name
43
+ object_name ||= data_token if data_token.is_a?(Symbol) # child :units => "units", whatever the table (#394)
37
44
  object_name ||= data.table_name if data.respond_to?(:table_name)
38
45
 
39
46
  if object_name.nil? && data.respond_to?(:first)
@@ -41,7 +48,6 @@ module Rabl
41
48
  object_name = data_name(first).to_s.pluralize if first.present?
42
49
  end
43
50
 
44
- object_name ||= data_token if data_token.is_a?(Symbol)
45
51
  object_name
46
52
  elsif is_object?(data) # data is an object
47
53
  object_name = object_root_name if object_root_name
@@ -82,6 +88,8 @@ module Rabl
82
88
  # is_collection?(@user) => false
83
89
  # is_collection?([]) => true
84
90
  def is_collection?(obj, follow_symbols = true)
91
+ return false if data_item?(obj)
92
+
85
93
  data_obj = follow_symbols ? data_object(obj) : obj
86
94
  data_obj &&
87
95
  data_obj.respond_to?(:map) && data_obj.respond_to?(:each) &&
@@ -89,6 +97,12 @@ module Rabl
89
97
  defined?(Hashie::Mash) && data_obj.is_a?(Hashie::Mash))
90
98
  end
91
99
 
100
+ # True for the Hash item that `extends` handed to this engine as its object (#396).
101
+ # It is data: never `@object => :name` alias syntax, and never a collection.
102
+ def data_item?(obj)
103
+ obj.is_a?(Hash) && defined?(@_data_item) && obj.equal?(@_data_item)
104
+ end
105
+
92
106
  # Returns the context_scope wrapping this engine, used for retrieving data, invoking methods, etc
93
107
  # In Rails, this is the controller and in Padrino this is the request context
94
108
  def context_scope
@@ -128,10 +142,14 @@ module Rabl
128
142
  return if object.nil?
129
143
 
130
144
  options.reverse_merge!({
131
- :format => "hash".freeze,
145
+ :format => "hash",
132
146
  :view_path => view_path,
147
+ :nested => true,
133
148
  :root => (options[:root] || false)
134
149
  })
150
+ options[:parent_engine] = rabl_template_engine
151
+ # a nested template renders as a hash, but looks its templates up in the format being rendered
152
+ options[:source_format] ||= manual_source_format if respond_to?(:manual_source_format, true)
135
153
 
136
154
  Engine.new(options[:source], options).apply(context_scope, :object => object, :locals => options[:locals], &block)
137
155
  end
@@ -139,12 +157,12 @@ module Rabl
139
157
  # Fetches a key from the cache and stores rabl template result otherwise
140
158
  # fetch_from_cache('some_key') { ...rabl template result... }
141
159
  def fetch_result_from_cache(cache_key, cache_options = {}, &block)
142
- expanded_cache_key = ActiveSupport::Cache.expand_cache_key(cache_key, :rabl)
160
+ expanded_cache_key = TemplateCacheKey.expand(cache_key)
143
161
  Rabl.configuration.cache_engine.fetch(expanded_cache_key, cache_options, &block)
144
162
  end
145
163
 
146
164
  def write_result_to_cache(cache_key, cache_options = {}, &block)
147
- expanded_cache_key = ActiveSupport::Cache.expand_cache_key(cache_key, :rabl)
165
+ expanded_cache_key = TemplateCacheKey.expand(cache_key)
148
166
  result = yield
149
167
  Rabl.configuration.cache_engine.write(expanded_cache_key, result, cache_options)
150
168
  result
@@ -159,13 +177,22 @@ module Rabl
159
177
  end
160
178
  end
161
179
 
180
+ protected
181
+ # Returns the engine of the template being rendered, if any (see Engine#rabl_template_identity)
182
+ def rabl_template_engine
183
+ nil
184
+ end
185
+
162
186
  module Escaper
187
+ # Returns escaped copies; the response may be shared with the cache, so it is never changed in place.
163
188
  def escape_output(response)
164
189
  case response
165
190
  when Hash
166
- response.each{|k,v| response[k] = escape_value(v) }
191
+ escaped = response.dup
192
+ response.each { |k, v| escaped[k] = escape_value(v) }
193
+ escaped
167
194
  when Array
168
- response.map!{|v| escape_value(v) }
195
+ response.dup.map! { |v| escape_value(v) }
169
196
  else
170
197
  response
171
198
  end