Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
62aefc2
V2 serializer refactor for huge perf improvement
jhollinger Apr 15, 2026
bb1faa0
Have dedicated structs for field reflection so people don't depend on…
jhollinger Apr 22, 2026
0f06c88
Add options to reflections
jhollinger Apr 22, 2026
910f1a2
Reduce V2 serializer allocations by ~23% via fast path
scottmyron Apr 23, 2026
6af0eee
Cleanup
jhollinger Apr 24, 2026
6057122
Merge pull request #583 from scottmyron/sm/release-2.0-faster-tweaks
jhollinger Apr 28, 2026
39cb22a
Get back to a single extract & serialization path. Sacrifices a *litt…
jhollinger Apr 28, 2026
719637c
Ensure field conditional & default status is checked after copying do…
jhollinger Apr 28, 2026
6b70e88
Too difficult to use separate internal/reflected field defs. Use inte…
jhollinger Apr 28, 2026
4da5c95
Allow options to be passed when multiple fields are defined with `fie…
jhollinger Apr 28, 2026
4cf8261
Centralize incrementing serialization depth
jhollinger Apr 29, 2026
0a7319a
Test to prove there's a way to share partials across classes
jhollinger Apr 29, 2026
8f29c75
Replace instance_exec with simple calls in most cases.
jhollinger Apr 30, 2026
ca1a0e9
Simplify extraction code
jhollinger Apr 30, 2026
55c77fd
Rename Context::Render to Context::Init
jhollinger Apr 30, 2026
8d281b5
Only allow around_result to change options. And don't freeze them unt…
jhollinger May 1, 2026
364ec21
Since V2 doesn't currently support init-per-render extensions, it doe…
jhollinger May 1, 2026
659b7e1
Move ExtensionHelpers back into Extension since it's only used in one…
jhollinger May 1, 2026
aad5212
More granular decisions on allocating field context objects (will sav…
jhollinger May 1, 2026
8e39ab6
Rename the 'from' option to 'source'. Reads much better in the docs
jhollinger May 1, 2026
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
52 changes: 4 additions & 48 deletions lib/blueprinter/extension.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,23 +10,19 @@ module Blueprinter
# - around_result
# - around_blueprint_init
# - around_serialize_object | around_serialize_collection
# - around_blueprint
# - around_field_value | around_object_value | around_collection_value
# - around_blueprint_init …
# - around_field_value | around_object_value | around_collection_value
# - around_blueprint_init …
#
# V1 hook call order:
# - pre_render
#
class Extension
include V2::Helpers

HOOKS = %i[
around_hook
around_result
around_blueprint_init
around_serialize_object
around_serialize_collection
around_blueprint
around_field_value
around_object_value
around_collection_value
Expand All @@ -41,48 +37,8 @@ def self.hooks
# If this returns true, around_hook will not be called when this extension's hooks are run. Used by core extensions.
def hidden? = false

# around_result TODO
# @param context [Blueprinter::V2::Context::Result]

# around_serialize_object: Runs around serialization of a Blueprint object.
# @param context [Blueprinter::V2::Context::Object]

# around_serialize_collection: Runs around serialization of a Blueprint collection.
# @param context [Blueprinter::V2::Context::Object]

# around_blueprint: Runs around serialization of every Blueprint.
# @param context [Blueprinter::V2::Context::Object]

# around_field_value TODO

# around_object_value TODO

# around_collection_value TODO

# blueprint_fields: Returns the fields that should be included in the correct order. Default is all fields in the order
# in which they were defined.
# NOTE If there are multiple blueprint_fields hooks, only the last one is called.
# NOTE Only runs once per Blueprint per render.
# @param context [Blueprinter::V2::Context::Render]
# @return [Array<Blueprinter::V2::Fields::Field|Blueprinter::V2::Fields::Object|Blueprinter::V2::Fields::Collection>]

# blueprint_setup: Called once per blueprint per render. A common use is to pre-calculate certain options
# and cache them in context.data, so we don't have to recalculate them for every field.
# @param context [Blueprinter::V2::Context::Render]

# around_hook: Instrument extension hook calls. MUST yield!
# @param extension [Blueprinter::Extension] Instance of the extension
# @param hook [Symbol] Name of hook being called

# pre_render: Called eary during "render" in V1, this method receives the object to be rendered and
# may return a modified (or new) object to be rendered.
# @param object [Object] The object to be rendered
# @param blueprint [Class] The Blueprinter class
# @param view [Symbol] The blueprint view
# @param options [Hash] Options passed to "render"
# @return [Object] The object to continue rendering

private
# Skip the current field and halt further field hooks
def skip! = throw V2::Serializer::SIGNAL, V2::Serializer::SIG_SKIP

# Helper for around_result hooks to declare that a result is "final"
def final(val) = V2::Context::Final.new(val)
Expand Down
2 changes: 1 addition & 1 deletion lib/blueprinter/extensions/field_order.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ def initialize(&sorter)
@sorter = sorter
end

# @param ctx [Blueprinter::V2::Context::Render]
# @param ctx [Blueprinter::V2::Context::Init]
def around_blueprint_init(ctx)
ctx.fields = ctx.fields.sort(&@sorter)
yield ctx
Expand Down
5 changes: 2 additions & 3 deletions lib/blueprinter/extractors/association_extractor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,10 @@ def extract_v2(value, blueprint, local_options, options)
store = local_options[:v2_store] || {}
depth = local_options[:v2_depth] || 1
instances = local_options[:v2_instances] || V2::InstanceCache.new
serializer = instances.serializer(blueprint[view], local_options.except(:v2_instances), store, depth + 1)
if value.is_a?(Enumerable) && !value.is_a?(Hash)
serializer.collection(value, depth: depth + 1)
blueprint[view].serializer.collection(value, local_options, instances:, store:, depth: depth + 1)
else
serializer.object(value, depth: depth + 1)
blueprint[view].serializer.object(value, local_options, instances:, store:, depth: depth + 1)
end
end

Expand Down
50 changes: 30 additions & 20 deletions lib/blueprinter/hooks.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ def initialize(extensions)
ext.class.hooks.each { |hook| @hooks[hook] << ext }
end
@hooks.freeze
@reversed_hooks ||= @hooks.transform_values(&:reverse).freeze
@hook_around_hook = registered? :around_hook
end

Expand Down Expand Up @@ -38,43 +37,54 @@ def [](hook) = @hooks.fetch(hook)
# @param require_yield [Boolean] Throw an exception if a hook doesn't yield
# @return [Object] Object returned from the outer hook (or from the given block, if there are no hooks)
#
def around(hook, ctx, require_yield: false, &inner)
def around(hook, ctx, require_yield: false, &)
hooks = @hooks.fetch(hook)
_around(hooks, hook, 0, ctx, ctx.class, inner, require_yield:)
_around(hooks, hook, 0, ctx, ctx.class, require_yield:, &)
end

private

def call(ext, hook, ctx, &)
return ext.public_send(hook, ctx, &) if !@hook_around_hook || ext.hidden? || hook == :around_hook

result = nil
hooks = @hooks.fetch(:around_hook)
hook_ctx = V2::Context::Hook.new(ctx.blueprint, ctx.fields, ctx.options, ext, hook)
_around(hooks, :around_hook, 0, hook_ctx, NilClass, lambda do |_|
result = ext.public_send(hook, ctx, &)
end, require_yield: true)
result
end

def _around(hooks, hook, idx, ctx, expected_yield, inner, require_yield: false)
# Runs hooks recursively
def _around(hooks, hook, idx, ctx, expected_yield, require_yield: false, &)
ext = hooks[idx]
return inner.call(ctx) if ext.nil?
return yield ctx if ext.nil?

yielded = false
result = call(ext, hook, ctx) do |yielded_ctx|
yielded = true
yielded ||= true
unless yielded_ctx.is_a? expected_yield
msg = "should yield `#{expected_yield.name}` but yielded `#{yielded_ctx.inspect}`"
raise Errors::ExtensionHook.new(ext, hook, msg)
end

ctx = yielded_ctx.dup if yielded_ctx
_around(hooks, hook, idx + 1, ctx, expected_yield, inner, require_yield:)
ctx = yielded_ctx if yielded_ctx
_around(hooks, hook, idx + 1, ctx, expected_yield, require_yield:, &)
end
raise Errors::ExtensionHook.new(ext, hook, 'did not yield') if require_yield && !yielded

result
end

# Calls a hook on an extension. If the `around_hook` hook is registered it's wrapped around the call.
def call(ext, hook, ctx, &)
return ext.public_send(hook, ctx, &) if !@hook_around_hook || ext.hidden? || hook == :around_hook

hooks = @hooks.fetch(:around_hook)
# Hacky, but re-using this context object saves tons of time
hook_ctx = Thread.current[:_blueprinter_hook_ctx] ||= V2::Context::Hook.new
hook_ctx.blueprint = ctx.blueprint
hook_ctx.fields = ctx.fields
hook_ctx.options = ctx.options
hook_ctx.extension = ext
hook_ctx.hook = hook
hook_ctx.store = ctx.store
hook_ctx.depth = ctx.depth
result = nil
_around(hooks, :around_hook, 0, hook_ctx, NilClass, require_yield: true) do
# return the inner hook's value, not around_hook's
result = ext.public_send(hook, ctx, &)
end
result
end
end
end
4 changes: 2 additions & 2 deletions lib/blueprinter/v2.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ module V2
autoload :Context, 'blueprinter/v2/context'
autoload :DSL, 'blueprinter/v2/dsl'
autoload :Extensions, 'blueprinter/v2/extensions'
autoload :Extractor, 'blueprinter/v2/extractor'
autoload :Extractors, 'blueprinter/v2/extractors'
autoload :FieldLogic, 'blueprinter/v2/field_logic'
autoload :FieldSerializers, 'blueprinter/v2/field_serializers'
autoload :Formatter, 'blueprinter/v2/formatter'
autoload :Helpers, 'blueprinter/v2/helpers'
autoload :InstanceCache, 'blueprinter/v2/instance_cache'
autoload :Reflection, 'blueprinter/v2/reflection'
autoload :Render, 'blueprinter/v2/render'
Expand Down
17 changes: 11 additions & 6 deletions lib/blueprinter/v2/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ module V2
class Base
extend DSL
extend Reflection
include Helpers

class << self
# @return [Hash] Options set on this Blueprint
Expand Down Expand Up @@ -51,6 +50,11 @@ def self.inherited(subclass)
subclass.eval_mutex = Mutex.new
end

def self.serializer
eval! unless @serializer
@serializer
end

# A descriptive name for the Blueprint view, e.g. "WidgetBlueprint.extended"
def self.inspect = blueprint_name

Expand All @@ -74,7 +78,7 @@ def self.append_name(name)
# @return [Class] A descendent of Blueprinter::V2::Base
#
def self.[](name)
eval! unless @evaled
eval! unless @serializer
child, children = name.to_s.split('.', 2)
view = views[child.to_sym] || raise(Errors::UnknownView, "View '#{child}' could not be found in Blueprint '#{self}'")
children ? view[children] : view
Expand All @@ -101,10 +105,10 @@ def self.render_collection(objs, options = {})
# Apply partials and field exclusions
# @api private
def self.eval!(lock: true)
return if @evaled
return if @serializer

if lock
eval_mutex.synchronize { run_eval! unless @evaled }
eval_mutex.synchronize { run_eval! unless @serializer }
else
run_eval!
end
Expand All @@ -118,11 +122,12 @@ def self.run_eval!
options.freeze
formatters.freeze
schema.freeze
serializer = Serializer.new(self)
schema.each_value do |f|
f.options&.freeze
f.options.freeze
f.freeze
end
@evaled = true
@serializer = serializer
end

# @api private
Expand Down
Loading
Loading