Skip to content

Commit b1eb4ac

Browse files
Compiled views performance optimization
Signed-off-by: Piotr Pasciak <piotrek.pasciak@gmail.com>
1 parent d1e407b commit b1eb4ac

10 files changed

Lines changed: 744 additions & 12 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
## Unreleased
2-
--
2+
- 🚀 [FEATURE] Adds opt-in compiled views (`config.compile_views = true`), which compile each view into a plain Ruby method on first render. Output is identical to standard rendering, and rendering is several times faster.
33

44
## 1.3.0 - 2026/04/14
55
- 🚀 [FEATURE] Adds support for `Symbol#to_proc` syntax in fields and identifiers. See [#546](https://github.com/procore-oss/blueprinter/pull/546). Thanks to [@tob1k](https://github.com/tob1k).

‎README.md‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1153,6 +1153,48 @@ Gems can be created that enrich `blueprinter`'s core functionality via extension
11531153

11541154
</details>
11551155

1156+
<details>
1157+
<summary>Compiled Views</summary>
1158+
1159+
1160+
Blueprinter can compile each view into a plain Ruby method on first render, which makes rendering several times
1161+
faster. Output is identical to standard rendering. It is opt-in:
1162+
1163+
```ruby
1164+
Blueprinter.configure do |config|
1165+
config.compile_views = true
1166+
end
1167+
```
1168+
1169+
A compiled view builds each object's Hash directly, much like a hand-written serializer would:
1170+
1171+
```ruby
1172+
# class UserBlueprint < Blueprinter::Base
1173+
# identifier :id
1174+
# fields :first_name, :last_name
1175+
# end
1176+
#
1177+
# compiles to (roughly):
1178+
{ id: object.id, first_name: object.first_name, last_name: object.last_name }
1179+
```
1180+
1181+
Fields, blocks, `name`, `if`/`unless` and `exclude_if_nil` are compiled, as are associations with a fixed
1182+
`:blueprint` (extensions still run for them). Everything else (`default`, `default_if`, `datetime_format`, custom
1183+
extractors, associations with a Proc `:blueprint` or `:options`) uses standard rendering within the compiled view.
1184+
1185+
Things to know:
1186+
- While `datetime_format`, `field_default`, `association_default`, `if` or `unless` is set in the global configuration,
1187+
renders use standard rendering. Hash objects are also always rendered the standard way.
1188+
- Compiled views are stored with Blueprinter's view cache and rebuilt whenever it is refreshed (e.g. after a `view`
1189+
block).
1190+
- Compiled fields read values directly (`object.name`), so patches applied directly to Blueprinter's internal
1191+
`AutoExtractor`, `PublicSendExtractor` or `AssociationExtractor` classes are not used for them. Custom extractors
1192+
configured with `extractor_default` or `extractor:` are always used.
1193+
- Associations rendered by a compiled view receive the parent's options Hash when they use the same view (standard
1194+
rendering passes an equal copy), and don't call the associated Blueprint's `hashify` or `prepare_data` methods.
1195+
1196+
</details>
1197+
11561198
<details>
11571199
<summary>Deprecations</summary>
11581200

‎lib/blueprinter/configuration.rb‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ module Blueprinter
88
class Configuration
99
attr_accessor(
1010
:association_default,
11+
:compile_views,
1112
:custom_array_like_classes,
1213
:datetime_format,
1314
:default_transformers,
@@ -37,6 +38,7 @@ def initialize
3738
@default_transformers = []
3839
@custom_array_like_classes = []
3940
@extensions = Extensions.new
41+
@compile_views = false
4042
end
4143

4244
def extensions=(list)

‎lib/blueprinter/extensions.rb‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ def to_a
2121
@extensions.dup
2222
end
2323

24+
def empty?
25+
@extensions.empty?
26+
end
27+
2428
# Appends an extension
2529
def <<(ext)
2630
@extensions << ext

‎lib/blueprinter/rendering.rb‎

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -126,24 +126,37 @@ def prepare_data(object, view_name, local_options)
126126
# Since we're currently providing the current view in the local_options hash when we extract fields, we can merge
127127
# it in ahead of time to avoid allocating a new hash for every field extraction.
128128
local_options_with_view = local_options.merge(view: view_name)
129+
compiled = compiled_view(view_name)
129130

130131
if array_like?(object)
131132
object.map do |obj|
132-
object_to_hash(
133-
obj,
134-
view_name:,
135-
local_options: local_options_with_view
136-
)
133+
next compiled.call(obj, local_options_with_view) if compiled && !obj.is_a?(Hash)
134+
135+
object_to_hash(obj, view_name:, local_options: local_options_with_view)
137136
end
138137
else
139-
object_to_hash(
140-
object,
141-
view_name:,
142-
local_options: local_options_with_view
143-
)
138+
hash_for(object, view_name, local_options_with_view, compiled)
144139
end
145140
end
146141

142+
# Hashes always use the standard path: the compiled view reads values with method calls
143+
def hash_for(object, view_name, local_options, compiled)
144+
return compiled.call(object, local_options) if compiled && !object.is_a?(Hash)
145+
146+
object_to_hash(object, view_name:, local_options:)
147+
end
148+
149+
# The compiled view (see ViewCompiler), or nil when compiled views are off or a global setting that compiled views
150+
# don't handle is set
151+
def compiled_view(view_name)
152+
config = Blueprinter.configuration
153+
return unless config.compile_views
154+
return unless config.datetime_format.nil? && config.field_default.nil? && config.association_default.nil? &&
155+
config.if.nil? && config.unless.nil?
156+
157+
view_collection.compiled(view_name)
158+
end
159+
147160
def object_to_hash(object, view_name:, local_options:)
148161
result_hash = {}
149162

‎lib/blueprinter/view_collection.rb‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# frozen_string_literal: true
22

33
require 'blueprinter/view'
4+
require 'blueprinter/view_compiler'
45

56
module Blueprinter
67
# @api private
@@ -70,6 +71,18 @@ def transformers(view_name)
7071
@cache[:transformers][view_name]
7172
end
7273

74+
# Returns the compiled form of the provided View (see ViewCompiler). Compiled lazily and kept with the rest of the
75+
# cache, so it is rebuilt whenever the cache is invalidated. Two threads may compile the same view at once; both
76+
# produce an equivalent module and either one is kept.
77+
# @param [String] view_name
78+
# @return [Module]
79+
def compiled(view_name)
80+
ensure_cached!
81+
82+
cache = @cache
83+
cache[:compiled][view_name] ||= ViewCompiler.compile(cache[:fields][view_name], cache[:transformers][view_name])
84+
end
85+
7386
# @param [String] view_name
7487
# @return [View]
7588
def [](view_name)
@@ -106,7 +119,7 @@ def ensure_cached!
106119
transformers[view_name] = build_transformers(view_name).freeze
107120
end
108121

109-
@cache = { fields: fields.freeze, transformers: transformers.freeze }.freeze
122+
@cache = { fields: fields.freeze, transformers: transformers.freeze, compiled: {} }.freeze
110123
end
111124
end
112125

‎lib/blueprinter/view_compiler.rb‎

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
# frozen_string_literal: true
2+
3+
module Blueprinter
4+
# @api private
5+
#
6+
# Compiles a view into a module whose `call(object, options)` builds the object's Hash with a Hash literal and direct
7+
# method calls, e.g. `{ id: object.id, name: object.name }`. Used when `Blueprinter.configuration.compile_views` is on.
8+
#
9+
# Only simple fields are compiled: values read with AutoExtractor (by method or block), optionally with if/unless and
10+
# exclude_if_nil. Every other field (associations, defaults, datetime formats, custom extractors) calls Field#skip? and
11+
# Field#extract, exactly like standard rendering.
12+
#
13+
# The result is only valid while datetime_format, field_default and the global if/unless are unset;
14+
# Rendering#prepare_data checks that on every render. Blocks, callables, fields, transformers and unusual names live in
15+
# the module's REFS constant and are referenced by index, so user-provided text is never interpolated as code.
16+
module ViewCompiler
17+
# Method names that can be called directly as `object.name`
18+
CALLABLE_METHOD = /\A[a-z_][a-zA-Z0-9_]*[?!]?\z/
19+
# Symbols that can be written as `name:` keys or `:name` literals
20+
LITERAL_SYMBOL = /\A[a-zA-Z_][a-zA-Z0-9_]*\z/
21+
22+
module_function
23+
24+
# @return [Module] responds to `call(object, options)` and returns the object's Hash
25+
def compile(fields, transformers)
26+
refs = []
27+
ref = ->(value) { "REFS[#{refs.push(value).size - 1}]" }
28+
body = body(fields, transformers, ref)
29+
30+
Module.new.tap do |compiled|
31+
compiled.const_set(:REFS, refs.freeze)
32+
compiled.module_eval(<<~RUBY, __FILE__, __LINE__ + 1)
33+
def self.call(object, options) # def self.call(object, options)
34+
#{body} # { id: object.id, name: object.name }
35+
end # end
36+
RUBY
37+
end
38+
end
39+
40+
def body(fields, transformers, ref)
41+
literal = []
42+
statements = []
43+
keys = {}
44+
45+
fields.each do |field|
46+
key = key_for(field.name, ref)
47+
value = simple_value(field, ref)
48+
# A repeated name keeps its first position and its last value, like assigning a Hash key twice
49+
if value && statements.empty? && literal_field?(field, keys)
50+
literal << "#{key} => #{value}"
51+
else
52+
statements << statement(field, key, value, ref)
53+
end
54+
keys[field.name] = true
55+
end
56+
transformers.each { |transformer| statements << "#{ref.call(transformer)}.transform(h, object, options)" }
57+
58+
statements.empty? ? "{ #{literal.join(', ')} }" : "h = { #{literal.join(', ')} }\n#{statements.join("\n")}\nh"
59+
end
60+
61+
# The value expression for a field compiled directly, or nil for a field that uses standard rendering
62+
def simple_value(field, ref)
63+
options = field.options
64+
return nil if options[:datetime_format] || options[:default_if] || options.key?(:default)
65+
return association_value(field, ref) if options[:association]
66+
return nil unless field.extractor.instance_of?(AutoExtractor)
67+
68+
read(field, ref)
69+
end
70+
71+
# Same as AssociationExtractor#extract + the associated Blueprint's #hashify, for associations with a static
72+
# blueprint and no :options. nil stays nil, because association_default is unset (see Rendering#compiled_view).
73+
def association_value(field, ref)
74+
extractor = field.extractor
75+
blueprint = field.options[:blueprint]
76+
view = field.options[:view] || :default
77+
return nil unless extractor.instance_of?(AssociationExtractor) &&
78+
extractor.instance_variable_get(:@extractor).instance_of?(AutoExtractor) &&
79+
blueprint.is_a?(Class) && field.options[:options].nil? && blueprint.view?(view)
80+
81+
"((a = #{read(field, ref)}).nil? ? nil : ::Blueprinter::ViewCompiler.render_association(a, " \
82+
"#{ref.call(blueprint)}, #{ref.call(view)}, options))"
83+
end
84+
85+
# Same as AutoExtractor reading a value: BlockExtractor's calling convention, or PublicSendExtractor
86+
def read(field, ref)
87+
block = field.options[:block]
88+
if block
89+
call_args = block.parameters == [[:req], [:rest]] ? 'object' : 'object, **options'
90+
"#{ref.call(block)}.call(#{call_args})"
91+
elsif field.method.to_s.match?(CALLABLE_METHOD)
92+
"object.#{field.method}"
93+
else
94+
"object.public_send(#{ref.call(field.method)})"
95+
end
96+
end
97+
98+
# The steps of Rendering#hashify after its view check (done at compile time): extensions, then the same work as
99+
# prepare_data. The global settings were already checked for this render, so the child's compiled view is used
100+
# directly; options are only copied when the view differs, since their content would be the same.
101+
def render_association(value, blueprint, view, options)
102+
config = Blueprinter.configuration
103+
value = config.extensions.pre_render(value, blueprint, view, options) unless config.extensions.empty?
104+
options = options.merge(view:) unless options[:view] == view
105+
compiled = blueprint.view_collection.compiled(view)
106+
107+
if config.array_like_classes.any? { |klass| value.is_a?(klass) }
108+
value.map { |item| render_one(item, blueprint, view, options, compiled) }
109+
else
110+
render_one(value, blueprint, view, options, compiled)
111+
end
112+
end
113+
114+
# Hashes use standard rendering, like in Rendering#prepare_data
115+
def render_one(object, blueprint, view, options, compiled)
116+
return compiled.call(object, options) unless object.is_a?(Hash)
117+
118+
blueprint.__send__(:object_to_hash, object, view_name: view, local_options: options)
119+
end
120+
121+
def statement(field, key, value, ref)
122+
unless value
123+
# Standard rendering for this field
124+
field_ref = ref.call(field)
125+
value = "#{field_ref}.extract(object, options)"
126+
condition = "!#{field_ref}.skip?(#{key}, object, options)"
127+
end
128+
condition ||= field_conditions(field, key, ref)
129+
store = field.options[:exclude_if_nil] ? "v = #{value}\nh[#{key}] = v unless v.nil?" : "h[#{key}] = #{value}"
130+
condition ? "if #{condition}\n#{store}\nend" : store
131+
end
132+
133+
# Same as Field#skip? for field-level if/unless (global ones are excluded by Rendering#prepare_data)
134+
def field_conditions(field, key, ref)
135+
parts = []
136+
parts << "#{ref.call(field.send(:if_callable))}.call(#{key}, object, options)" if field.options[:if]
137+
parts << "!#{ref.call(field.send(:unless_callable))}.call(#{key}, object, options)" if field.options[:unless]
138+
parts.empty? ? nil : parts.join(' && ')
139+
end
140+
141+
# Unconditional fields that are always stored and whose name is not repeated can go in the Hash literal
142+
def literal_field?(field, keys)
143+
!field.options[:if] && !field.options[:unless] && !field.options[:exclude_if_nil] && !keys.key?(field.name)
144+
end
145+
146+
def key_for(name, ref)
147+
name.is_a?(Symbol) && name.to_s.match?(LITERAL_SYMBOL) ? ":#{name}" : ref.call(name)
148+
end
149+
end
150+
end

0 commit comments

Comments
 (0)