rabl 0.19.0 → 0.19.1

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8bcb492fc67214455ff0bce236a63bdf0d2cb8a696253f3c3b1fa2eef3d6a08a
4
- data.tar.gz: 79a96efbd90a60df008fd50313ef1f097bfbac97cee4298529491e00dac3f13c
3
+ metadata.gz: f1bfaeaa40af7667acd5ed068e1bd0b54dd5f201ec360bb78dd51fcb42198343
4
+ data.tar.gz: 5389a879fe511adcf009b51edbf47c77042374cac7582cd287a699ca7560caa8
5
5
  SHA512:
6
- metadata.gz: dfe2684ff81aa3b56be1a911da48f779f0493746cd996dd48d63715a9ebdfcfc544684cbf0b1fcd5ed86ba0526a536d356cbf10fc8fae48b02c7b7a9e5cc8ab1
7
- data.tar.gz: 5241f944033242864fe36ec1ddca572b35e0cbad72067340de2c7be76a8d4d43b854d6caaebd4fec9c303ac96488091d857764a06c81cd1d3f5daf79daf2f021
6
+ metadata.gz: 1071859094149f84a0d9127a9acd99101537278694390bab3d1a2eb1d5b59ac50466e222e7117a627df114fc5c2662eec9b7ea555bbb7b975737d9f979049c3e
7
+ data.tar.gz: 248bdcc28e051445cf08d4d8c6f5f5911ecb83cc453844514cf5460d240b522b12257e41a4ef82b3b2f47787079142ec56a0626e227306931ebd42764e1b7c09
data/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # CHANGELOG
2
2
 
3
+ ## 0.19.1 (Nov 5, 2026)
4
+
5
+ ### Other changes
6
+
7
+ * With `camelize_keys` on, a key is camelized once and remembered rather than for each item of a collection: a collection hands the same names out for every item, and camelizing one takes about 0.5 µs and six objects for a one-word name and about twenty for one with three words. Measured on a 50-item collection whose template has three `attributes`, one `node` and one `child`, six key names per item, YJIT off: with `camelize_keys = :lower` the render went from about 582 µs and 4050 allocations to about 457 µs and 2000 on Ruby 3.3.12, about 1.3x, and from about 592 µs and 4054 allocations to about 464 µs and 2004 on Ruby 4.0.7, where the same render without `camelize_keys` takes about 444 µs and 2000 on 3.3.12 and 453 µs and 2004 on 4.0.7 and is unchanged, so a template with more keys per item gains more. The names are kept in two maps, one per case style, so each style keeps its own; a map is emptied when it holds 10,000 names and a name longer than 128 bytes is camelized each time rather than kept, so a template that names keys at runtime (`node(field.key)`, `attribute :as => "field_#{i}"`, a name taken from user input) cannot grow it without bound. A name is kept as a frozen copy, because a Hash keeps an unfrozen String subclass such as `ActiveSupport::SafeBuffer` as it is and a caller that changed such a name in place would file another name under the camelized value; and a name keeps the acronyms it was first camelized with, so an inflection added after a name was first seen doesn't reach that name. The output is otherwise unchanged (@tagliala; change written by DeepSeek Flash)
8
+
9
+ * A template a collection uses for each item through `extends` or `partial` is now read and resolved once per render rather than once per item. With the default `cache_sources` off, every item ran the whole lookup itself: outside Rails, with a view path passed and one in `Rabl.configuration.view_paths`, which `Rabl::Renderer`'s lookup adds to it, one lookup costs about 117 µs and 62 allocations on Ruby 4.0.7 and about 110 µs on 3.3.12, YJIT off, against 1.8 µs and 4 with `cache_sources` on. The sources a render reads are now collected for the length of that render and dropped when it returns, so a template changed between renders is still read again, one changed while the render runs is not read again until the next render, and a render that raises leaves nothing behind. A render started inside the collection (a `Rabl::Renderer` in a `node`, a nested render under Rails) uses the collection it finds, whose keys are complete, so the render around it keeps its own sources. Outside Rails the collection keys a lookup by the values it depends on (the format, the locale, the default locale and the object its fallbacks come from, the app's views under Sinatra) and not by the numbers `LOOKUP_IDS` and `MANUAL_LOOKUP_IDS` keep for the life of the process, so the collection's keys add nothing to those tables nor to `I18n.fallbacks`. With `cache_sources` on or off, a template with more than one candidate file for a name still asks I18n for the locale's fallbacks when it ranks them, which keeps that locale in `I18n.fallbacks`, as it did before; with `cache_sources` on the tables and `I18n.fallbacks` also gain an entry per locale looked up in, as before. Under Rails the numbers come from the lookup details and view paths Rails itself keeps, so they are used as they are and a view path prepended for one request adds one, as it does to Rails; a Padrino lookup, which depends on the request's controller and content type, is not collected per render. Measured outside Rails on a 50-item collection whose template is three `attributes`, one `node` and one `child` plus `extends "posts/show"` (the same output as one template with all of them), the render went from about 5.8 ms and 7540 allocations to about 0.95 ms and 4456 on Ruby 3.3.12, about 6.2x, and from about 7.0 ms and 7244 allocations to about 1.0 ms and 4454 on Ruby 4.0.7, about 7.0x; a heavier template, with two more `node`s, a `child`, a `glue` and a `partial` per item, went from about 12.1 ms and 16649 allocations to about 2.2 ms and 10480 on 3.3.12, about 5.4x, and from about 13.8 ms and 16103 to about 2.3 ms and 10522 on 4.0.7, about 5.9x, so the gain follows how much of the per-item cost the lookup was. Under Rails 8.1.4 on Ruby 4.0.7, rendering the same collection through `ApplicationController.render` with `cache_sources` off went from about 2.7 ms and 6376 allocations to about 1.9 ms and 5203, about 1.4x, where the same render with `cache_sources` on takes about 1.8 ms either way: Rails repeats a lookup in about 17 µs per item, the difference between that render with `cache_sources` on and off on the same code, against about 117 µs for the manual lookup, so it has less to save. The one-template version of the output is unchanged within the noise, one allocation more (the render's own collection), and a single object using `extends` allocates about ten more objects than before (the render's own collection, and a frozen copy of each lookup's template name and view paths). The output is unchanged (@tagliala; change written by DeepSeek Flash)
10
+
11
+ * `Rabl::Builder#cache_results` and `Rabl::Engine#cache_results` now ask their cheapest question first: whether `cache_all_output` is on, and whether the template set a cache key, before asking whether caching is configured. Under Rails that second question is `ActionController::Base.perform_caching`, a delegated call that allocates an object and takes about 250 ns on Ruby 4.0.7, Rails 8.1.4, YJIT off; with the defaults (`cache_all_output` off, no `cache` in the template) its answer could not change the guard, and it was computed once per object built and once per engine rendered. On Ruby 4.0.7, where the call allocates one object, a 50-item collection whose template has one `child` (100 builders, 51 engines) saves those 151 allocations and about 40 µs per render, about 1.08x; on Ruby 3.3 it allocates two, so the same collection saves 302. A single object saves 4 allocations and about 1 µs. The output is unchanged; with `cache_all_output` on, the builder runs the same checks in the cheap-first order, and an engine whose template sets no cache key still skips the call. Outside Rails both checks are plain configuration reads, so the difference is inside the noise and the allocation count is unchanged (@tagliala)
12
+
3
13
  ## 0.19.0 (Nov 1, 2026)
4
14
 
5
15
  ### Requirements
data/lib/rabl/builder.rb CHANGED
@@ -256,7 +256,8 @@ module Rabl
256
256
  # Caches the results of the block based on object cache_key
257
257
  # cache_results { compile_hash(options) }
258
258
  def cache_results(&block)
259
- if template_cache_configured? && Rabl.configuration.cache_all_output && @_object.respond_to?(:cache_key)
259
+ # cheap questions first: template_cache_configured? allocates under Rails
260
+ if Rabl.configuration.cache_all_output && template_cache_configured? && @_object.respond_to?(:cache_key)
260
261
  cache_key = [@_object, @options[:root_name], @options[:format]]
261
262
  engine = rabl_template_engine
262
263
  if engine
@@ -273,11 +274,9 @@ module Rabl
273
274
  end
274
275
 
275
276
  def create_key(name)
276
- if Rabl.configuration.camelize_keys
277
- name.to_s.camelize(Rabl.configuration.camelize_keys == :upper ? :upper : :lower).to_sym
278
- else
279
- name.to_sym
280
- end
277
+ config = Rabl.configuration
278
+ style = config.camelize_keys
279
+ style ? config.camelized_key(name, style == :upper) : name.to_sym
281
280
  end
282
281
  end
283
282
  end
@@ -122,6 +122,50 @@ module Rabl
122
122
  @exclude_nil_values = false
123
123
  @exclude_empty_values_in_collections = false
124
124
  @camelize_keys = false
125
+ @camelized_keys = { false => {}, true => {} }
126
+ end
127
+
128
+ # Names camelized in each style, by whether the first letter is upper case: a collection
129
+ # builds the same keys for each of its items, and camelizing one takes about 0.5 µs.
130
+ # ceiling: one entry per distinct key name, so a template that names keys at runtime
131
+ # (node(field.key)) keeps them for the life of the process until a new name arrives and the
132
+ # map is already full, when it is emptied; a name over CAMELIZED_KEY_LENGTH_LIMIT bytes isn't
133
+ # kept at all; and a name keeps the acronyms it was first camelized with, so an inflection
134
+ # added later doesn't reach a name that was already seen.
135
+ CAMELIZED_KEYS_LIMIT = 10_000
136
+ # A name longer than this, in bytes, is camelized each time instead of being kept, so a map
137
+ # holds at most CAMELIZED_KEYS_LIMIT names of up to this many bytes each
138
+ CAMELIZED_KEY_LENGTH_LIMIT = 128
139
+
140
+ # Files a new name in a style's map: the maps are shared by every thread
141
+ CAMELIZED_KEYS_LOCK = Mutex.new
142
+
143
+ # Returns name camelized in the given style, camelizing each kept name once
144
+ def camelized_key(name, upper = false)
145
+ keys = @camelized_keys[upper]
146
+ cached = keys[name]
147
+ return cached if cached
148
+
149
+ string = name.to_s
150
+ style = upper ? :upper : :lower
151
+ return string.camelize(style).to_sym if string.bytesize > CAMELIZED_KEY_LENGTH_LIMIT
152
+
153
+ # a Hash freezes a plain String key and copies it, but keeps an unfrozen String subclass,
154
+ # such as ActiveSupport::SafeBuffer, as it is: a caller that changes the name in place would
155
+ # file another name under the camelized value, so the key is copied and frozen here
156
+ # the map is shared by every thread, so a new name is filed under the lock, as in LOOKUP_IDS
157
+ key = Rabl.frozen_copy(name)
158
+ CAMELIZED_KEYS_LOCK.synchronize do
159
+ keys.fetch(key) do
160
+ keys.clear if keys.size >= camelized_keys_limit
161
+ keys[key] = string.camelize(style).to_sym
162
+ end
163
+ end
164
+ end
165
+
166
+ # How many names a style's map keeps before a new name empties it
167
+ def camelized_keys_limit
168
+ CAMELIZED_KEYS_LIMIT
125
169
  end
126
170
 
127
171
  # @return The JSON engine used to encode Rabl templates into JSON
data/lib/rabl/engine.rb CHANGED
@@ -176,6 +176,14 @@ module Rabl
176
176
  options[:root_name] = determine_object_root(data, root_name, options[:root])
177
177
  options[:template_engine] = self
178
178
 
179
+ # A template the collection uses for each item (extends, partial) is read once per render,
180
+ # not once per item. The render that starts a collection ends it, so a render started inside
181
+ # this one (a Rabl::Renderer in a node, a nested render under Rails) uses the collection it
182
+ # finds, whose keys are complete, and the render around it keeps its own sources. A nested
183
+ # engine renders inside a collection anyway, so it skips the look for one.
184
+ owns_source_cache = !@_options[:nested] && Rabl.render_source_cache.nil?
185
+ Rabl.begin_render_source_cache if owns_source_cache
186
+
179
187
  @_rendering = true
180
188
  result =
181
189
  if is_object?(data) || !data # object @user
@@ -190,6 +198,7 @@ module Rabl
190
198
  result
191
199
  ensure
192
200
  @_rendering = false
201
+ Rabl.end_render_source_cache if owns_source_cache
193
202
  end
194
203
 
195
204
  def to_dumpable(options = {})
@@ -627,7 +636,8 @@ module Rabl
627
636
  # Caches the results of the block based on object cache_key
628
637
  # cache_results { compile_hash(options) }
629
638
  def cache_results(&block)
630
- return yield unless template_cache_configured? && defined?(@_cache_key)
639
+ # cheap question first: template_cache_configured? allocates under Rails
640
+ return yield unless defined?(@_cache_key) && template_cache_configured?
631
641
 
632
642
  if cache_read_on_render?
633
643
  fetch_result_from_cache(cache_key, @_cache_options, &block)
data/lib/rabl/sources.rb CHANGED
@@ -28,7 +28,10 @@ module Rabl
28
28
  # fetch_source("show", :view_path => "...") => "...contents..."
29
29
  def fetch_source(file, options = {})
30
30
  resolver = source_resolver
31
+ # the process-wide cache keeps a number for each lookup for the life of the process; a
32
+ # collection that lives for one render only needs the lookup itself
31
33
  lookup = source_lookup(resolver) if Rabl.configuration.cache_sources
34
+ lookup = render_source_lookup(resolver) if lookup.nil? && Rabl.render_source_cache
32
35
  # a lookup that can't be told apart from others (see source_lookup) isn't cached
33
36
  return resolve_source(file, options, resolver) if lookup == false
34
37
 
@@ -85,9 +88,11 @@ module Rabl
85
88
  # details such as versioncake's versions) and the view paths; elsewhere the format
86
89
  # and locales the manual lookup prefers, and under Sinatra the app's views. Returns
87
90
  # false when a view path has no stable name, so the lookup isn't cached.
88
- # ceiling: outside Rails the cache (and MANUAL_LOOKUP_IDS) keeps an entry per locale looked
89
- # up in, as Rails' details keys do, so it is bounded by I18n.available_locales only while
90
- # I18n.enforce_available_locales is on (the default).
91
+ # ceiling: with cache_sources on, the cache, LOOKUP_IDS and MANUAL_LOOKUP_IDS keep an entry
92
+ # per distinct set of lookup details and view path list, and per locale looked up in, as
93
+ # Rails' own registry does, so outside Rails they are bounded by I18n.available_locales only
94
+ # while I18n.enforce_available_locales is on (the default) and by the locales requests ask
95
+ # for; under Rails a view path prepended for one request adds one, as it does to Rails.
91
96
  def source_lookup(resolver)
92
97
  case resolver
93
98
  when :rails
@@ -102,6 +107,38 @@ module Rabl
102
107
  end
103
108
  end
104
109
 
110
+ # Returns the lookup of the per-render collection: the same as source_lookup, without the
111
+ # numbers LOOKUP_IDS, MANUAL_LOOKUP_IDS and I18n.fallbacks keep for the life of the process.
112
+ # Outside Rails those gain an entry per locale looked up in, which is bounded only while
113
+ # I18n.enforce_available_locales is on, and a render that ends drops its collection anyway.
114
+ # Under Rails the numbers come from the lookup details Rails itself keeps, so they are used as
115
+ # they are. Padrino's lookup depends on the request too (its controller and content type), so
116
+ # it is not collected per render, as with cache_sources off.
117
+ def render_source_lookup(resolver)
118
+ case resolver
119
+ when :rails
120
+ source_lookup(resolver)
121
+ when :sinatra
122
+ [context_scope.settings.views, manual_lookup]
123
+ when :padrino
124
+ false
125
+ else
126
+ manual_lookup
127
+ end
128
+ end
129
+
130
+ # Returns what the manual lookup depends on, as values rather than as the number standing for
131
+ # it, and where the locale's fallbacks come from rather than the chain itself: asking I18n for
132
+ # a locale's fallbacks keeps that locale
133
+ def manual_lookup
134
+ format = manual_source_format
135
+ return [:manual, format] unless defined?(::I18n) && ::I18n.respond_to?(:config)
136
+
137
+ config = ::I18n.config
138
+ fallbacks = ::I18n.fallbacks if ::I18n.respond_to?(:fallbacks)
139
+ [:manual, format, config.locale, config.default_locale, fallbacks && fallbacks.__id__]
140
+ end
141
+
105
142
  # Returns the number standing for the format and locales the manual lookup prefers.
106
143
  # Nested Hashes of what the locales come from, as this runs on every source cache hit.
107
144
  def manual_lookup_id
data/lib/rabl/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rabl
4
- VERSION = "0.19.0"
4
+ VERSION = "0.19.1"
5
5
  end
data/lib/rabl.rb CHANGED
@@ -28,6 +28,10 @@ end
28
28
 
29
29
  # Rabl.register!
30
30
  module Rabl
31
+ # Fiber-local key of the sources read by the render in progress (see Rabl.begin_render_source_cache)
32
+ RENDER_SOURCE_CACHE = :_rabl_render_source_cache
33
+ private_constant :RENDER_SOURCE_CACHE
34
+
31
35
  class << self
32
36
 
33
37
  # Initialize RABL within an application
@@ -62,14 +66,15 @@ module Rabl
62
66
  # everything the lookup depends on (the file, the view path and the lookup details)
63
67
  # source_cache("users/index", ["path/to/view"], [:json]) { "/full/path/to/template/users/index" }
64
68
  def source_cache(file, view_path, lookup = nil)
65
- return yield unless Rabl.configuration.cache_sources
66
-
67
- @_source_cache ||= {}
69
+ # With cache_sources off, a template the collection uses for each item (extends, partial) is
70
+ # still read and resolved once per render rather than once per item. That cache belongs to
71
+ # the render, so a template changed between renders is still read again.
72
+ cache = Rabl.configuration.cache_sources ? (@_source_cache ||= {}) : render_source_cache
73
+ return yield unless cache
68
74
 
69
75
  # stores a frozen copy of the key, so later changes to the caller's strings don't reach it
70
- @_source_cache.fetch([file, view_path, lookup]) do
71
- @_source_cache[frozen_copy([file, view_path, lookup])] = yield
72
- end
76
+ key = [file, view_path, lookup]
77
+ cache.fetch(key) { cache[frozen_copy(key)] = yield }
73
78
  end
74
79
 
75
80
  # Resets the RABL source cache
@@ -77,6 +82,23 @@ module Rabl
77
82
  @_source_cache = {}
78
83
  end
79
84
 
85
+ # The template sources read so far by the render running in this fiber, if any
86
+ def render_source_cache
87
+ Thread.current[RENDER_SOURCE_CACHE]
88
+ end
89
+
90
+ # Internal: Starts collecting the sources the render about to run reads (see source_cache).
91
+ # The render that starts a collection ends it, so a render started inside another collects
92
+ # its own sources for as long as it runs and the render around it reads again afterwards.
93
+ def begin_render_source_cache
94
+ Thread.current[RENDER_SOURCE_CACHE] = {}
95
+ end
96
+
97
+ # Internal: Drops the collection the render that returns from it started.
98
+ def end_render_source_cache
99
+ Thread.current[RENDER_SOURCE_CACHE] = nil
100
+ end
101
+
80
102
  # Returns a copy of value whose Strings and Arrays are frozen, for cache keys that
81
103
  # must not change when the caller's objects do
82
104
  def frozen_copy(value)
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rabl
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.19.0
4
+ version: 0.19.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nathan Esquenazi
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-11-01 00:00:00.000000000 Z
11
+ date: 2026-11-06 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport