Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
## Unreleased
- 🐛 [BUGFIX] Fixes broken memoization of field-level `:if`/`:unless` conditions. Because `callable_from` returns `false` when no condition is configured, `||=` never memoized it, so conditions were re-resolved (including a global configuration lookup) for every field of every rendered object. Renders are now ~1.6x faster. Note: global `config.if`/`config.unless` are now read once per field on first use, so they must be set before rendering — consistent with `config.sort_fields_by`.
- 🐛 [BUGFIX] Fixes broken memoization of field-level `:if`/`:unless` conditions. Because `callable_from` returns `false` when no condition is configured, `||=` never memoized it, so conditions were re-resolved (including a global configuration lookup) for every field of every rendered object. Renders are now ~1.6x faster. Memoized conditions are keyed on a `Blueprinter::Configuration` generation counter that the compiled-attribute writers (and `reset_configuration!`) bump, so mutating global `config.if`/`config.unless` after a render still takes effect on the next render while keeping the hot path an integer comparison.

## 1.3.0 - 2026/04/14
- 🚀 [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).
Expand Down
4 changes: 4 additions & 0 deletions lib/blueprinter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,12 @@ def configure
end

# Resets global configuration.
#
# Bumps the generation counter as well: a fresh Configuration starts with the default values,
# which is itself a configuration change as far as any compiled Blueprint cache is concerned.
def reset_configuration!
@_configuration = nil
Configuration.bump_generation!
end
end
end
82 changes: 63 additions & 19 deletions lib/blueprinter/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,37 +6,73 @@

module Blueprinter
class Configuration
attr_accessor(
:association_default,
:custom_array_like_classes,
:datetime_format,
:default_transformers,
:deprecations,
:field_default,
:generator,
:if,
:method,
:sort_fields_by,
:unless
)
attr_reader :extensions, :extractor_default
# Attributes baked into a Blueprint's compiled render pipeline (see Field#compile) or into
# ViewCollection's cache. Mutating one of these must bump the generation counter, otherwise
# those caches keep serving behaviour derived from the previous configuration.
COMPILED_ATTRIBUTES = %i[
custom_array_like_classes
datetime_format
default_transformers
field_default
if
sort_fields_by
unless
].freeze

# Attributes re-read on every render, so they need no cache invalidation.
LIVE_ATTRIBUTES = %i[
association_default
deprecations
generator
method
].freeze

VALID_CALLABLES = %i[if unless].freeze

# Monotonic counter, deliberately class-level rather than per-instance: reset_configuration!
# installs a brand new Configuration, and an instance counter would restart at zero, making a
# cache built against the previous instance look current.
#
# Mutating configuration is a boot-time activity, so this is intentionally lock-free. A torn
# read can only delay a recompile by one render, never produce a corrupt cache -- ViewCollection
# serialises the rebuild itself.
@generation = 0

class << self
attr_reader :generation

def bump_generation!
@generation += 1
end
end

attr_accessor(*LIVE_ATTRIBUTES)
attr_reader(*COMPILED_ATTRIBUTES, :extensions, :extractor_default)

# Writers for compiled attributes invalidate dependent caches. Defined after the attr_reader
# calls above so these take precedence.
COMPILED_ATTRIBUTES.each do |attribute|
define_method(:"#{attribute}=") do |value|
instance_variable_set(:"@#{attribute}", value)
invalidate_compiled!
value
end
end

def initialize
@deprecations = :stderror
@association_default = nil
@custom_array_like_classes = []
@datetime_format = nil
@default_transformers = []
@deprecations = :stderror
@extensions = Extensions.new
@extractor_default = AutoExtractor
@field_default = nil
@generator = JSON
@if = nil
@method = :generate
@sort_fields_by = :name_asc
@unless = nil
@extractor_default = AutoExtractor
@default_transformers = []
@custom_array_like_classes = []
@extensions = Extensions.new
end

def extensions=(list)
Expand Down Expand Up @@ -64,6 +100,7 @@ def extractor_default=(extractor)
reset_default_extractor!

@extractor_default = extractor
invalidate_compiled!
end

# @return [Blueprinter::AutoExtractor]
Expand All @@ -73,6 +110,13 @@ def default_extractor

private

# Drops memoised values derived from configuration, and signals every ViewCollection to
# recompile on next use.
def invalidate_compiled!
@_array_like_classes = nil
self.class.bump_generation!
end

def reset_default_extractor!
@_default_extractor = nil
end
Expand Down
97 changes: 83 additions & 14 deletions lib/blueprinter/field.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# frozen_string_literal: true

require 'blueprinter/extractors/auto_extractor'

# @api private
module Blueprinter
class Field
Expand All @@ -18,31 +20,98 @@ def extract(object, local_options)
end

def skip?(field_name, object, local_options)
return true if if_callable && !if_callable.call(field_name, object, local_options)
resolve_callables!
return true if @_if_callable && !@_if_callable.call(field_name, object, local_options)

@_unless_callable && @_unless_callable.call(field_name, object, local_options)
end

unless_callable && unless_callable.call(field_name, object, local_options)
# Compiles this field into a closure that writes its rendered value into a result hash.
#
# ViewCollection calls this once per field while building its cache, so the per-field decisions
# -- which extractor applies, whether a datetime format is in play, whether a default is
# configured, whether :if/:unless conditions exist -- are resolved a single time instead of
# being re-derived for every object in a collection.
#
# @return [Proc] called with (result_hash, object, options)
def compile
simple? ? compile_simple : compile_general
end

private

# NOTE: `callable_from` returns `false` when no callable is configured, which is the common case.
# `||=` therefore never memoizes it, causing a full re-resolution (including a global config
# lookup) for every field of every object rendered. The `defined?` guard memoizes falsey values
# too, so resolution happens exactly once per field.
#
# As a result, global `config.if`/`config.unless` are read once per field, on first use. Set them
# before rendering. This matches `config.sort_fields_by`, which is likewise snapshotted when a
# ViewCollection is built.
def if_callable
return @_if_callable if defined?(@_if_callable)
# The overwhelmingly common field: read an attribute (or Hash key) and store it. With no
# conditions, block, datetime format or default in play, none of AutoExtractor's per-call
# branching is needed.
def compile_simple
name = @name
method = @method

@_if_callable = callable_from(:if)
lambda do |result_hash, object, _options|
result_hash[name] = object.is_a?(Hash) ? object[method] : object.public_send(method)
end
end

# Everything else keeps today's behaviour, going through the configured extractor.
def compile_general
name = @name
exclude_if_nil = options[:exclude_if_nil]

lambda do |result_hash, object, options_hash|
next if skip?(name, object, options_hash)

value = extract(object, options_hash)
next if value.nil? && exclude_if_nil

result_hash[name] = value
end
end

# Whether this field can take the fast path. Associations never qualify: their extractor is an
# AssociationExtractor, which recurses into another Blueprint.
def simple?
extractor.instance_of?(AutoExtractor) && plain_options? && plain_configuration?
end

def plain_options?
!options[:block] && !options[:exclude_if_nil] && !options[:datetime_format] &&
!options.key?(:default) && !options.key?(:default_if)
end

# A global datetime_format or field_default changes the value of every field, and a global
# :if/:unless adds a per-object condition, so none of those can be skipped.
def plain_configuration?
config = Blueprinter.configuration

config.datetime_format.nil? && config.field_default.nil? && !if_callable && !unless_callable
end

def if_callable
resolve_callables!
@_if_callable
end

def unless_callable
return @_unless_callable if defined?(@_unless_callable)
resolve_callables!
@_unless_callable
end

# Resolves both conditions once, then reuses them.
#
# NOTE: `callable_from` returns `false` when no callable is configured, which is the common
# case, so `||=` would never memoize and would re-resolve -- including a global configuration
# lookup -- for every field of every object rendered.
#
# Keyed on Configuration.generation so that mutating global `config.if`/`config.unless` still
# takes effect, while costing only an integer comparison on the hot path. Fields on the fast
# path never reach here at all, since their closure captures no condition.
def resolve_callables!
generation = Configuration.generation
return if @_callable_generation == generation

@_if_callable = callable_from(:if)
@_unless_callable = callable_from(:unless)
@_callable_generation = generation
end

def callable_from(condition)
Expand Down
33 changes: 10 additions & 23 deletions lib/blueprinter/rendering.rb
Original file line number Diff line number Diff line change
Expand Up @@ -127,38 +127,25 @@ def prepare_data(object, view_name, local_options)
# it in ahead of time to avoid allocating a new hash for every field extraction.
local_options_with_view = local_options.merge(view: view_name)

# Loop invariants: the compiled render closures and transformers for this view are identical
# for every object in a collection, so resolve them once here rather than per object.
compiled_fields = view_collection.compiled_fields_for(view_name)
transformers = view_collection.transformers(view_name)

if array_like?(object)
object.map do |obj|
object_to_hash(
obj,
view_name:,
local_options: local_options_with_view
)
object_to_hash(obj, compiled_fields:, transformers:, local_options: local_options_with_view)
end
else
object_to_hash(
object,
view_name:,
local_options: local_options_with_view
)
object_to_hash(object, compiled_fields:, transformers:, local_options: local_options_with_view)
end
end

def object_to_hash(object, view_name:, local_options:)
def object_to_hash(object, compiled_fields:, transformers:, local_options:)
result_hash = {}

view_collection.fields_for(view_name).each do |field|
next if field.skip?(field.name, object, local_options)

value = field.extract(object, local_options)
next if value.nil? && field.options[:exclude_if_nil]

result_hash[field.name] = value
end

view_collection.transformers(view_name).each do |transformer|
transformer.transform(result_hash, object, local_options)
end
compiled_fields.each { |render_field| render_field.call(result_hash, object, local_options) }
transformers.each { |transformer| transformer.transform(result_hash, object, local_options) }

result_hash
end
Expand Down
37 changes: 31 additions & 6 deletions lib/blueprinter/view_collection.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ def initialize
@sort_by_definition = Blueprinter.configuration.sort_fields_by.eql?(:definition)
@cache_mutex = Mutex.new
@cache = nil
@cache_generation = nil
end

def inherit(view_collection)
Expand Down Expand Up @@ -61,6 +62,19 @@ def fields_for(view_name)
@cache[:fields][view_name]
end

# Returns an array of compiled render closures for the provided View, one per field.
#
# Each closure captures its field's per-field decisions (extractor, datetime formatting,
# defaults, :if/:unless) resolved once at compile time, so the render loop just calls each with
# (result_hash, object, local_options). See Field#compile.
# @param [String] view_name
# @return [Array<Proc>]
def compiled_fields_for(view_name)
ensure_cached!

@cache[:compiled][view_name]
end

# Returns an array of Transformer objects for the provided View.
# @param [String] view_name
# @return [Array<Transformer>]
Expand Down Expand Up @@ -91,22 +105,33 @@ def identifier_fields
end

def ensure_cached!
# Fast path: no lock needed once the cache is populated (atomic reference read).
return if @cache
generation = Blueprinter::Configuration.generation

# Fast path: no lock needed once the cache is populated for the current configuration
# generation (atomic reference read plus an integer comparison). The generation guard is what
# lets compiled closures bake in global config (datetime_format, field_default, :if/:unless):
# mutating any of those bumps the generation and forces a rebuild here.
return if @cache && @cache_generation == generation

@cache_mutex.synchronize do
# Re-check after acquiring the lock; another thread may have built the cache first.
return if @cache
# Re-read under the lock; configuration may have changed, or another thread may have built
# the cache first.
generation = Blueprinter::Configuration.generation
return if @cache && @cache_generation == generation

fields = {}
transformers = {}
compiled = {}

views.each_key do |view_name|
fields[view_name] = build_fields_for(view_name).freeze
view_fields = build_fields_for(view_name).freeze
fields[view_name] = view_fields
compiled[view_name] = view_fields.map(&:compile).freeze
transformers[view_name] = build_transformers(view_name).freeze
end

@cache = { fields: fields.freeze, transformers: transformers.freeze }.freeze
@cache = { fields: fields.freeze, transformers: transformers.freeze, compiled: compiled.freeze }.freeze
@cache_generation = generation
end
end

Expand Down
Loading