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
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ gem 'minitest', '~> 6.0'
gem 'rake', '~> 13.0'
gem 'sqlite3', '~> 2.6'
gem 'ostruct', '~> 0.6'
gem 'blueprinter', git: 'https://github.com/procore-oss/blueprinter.git', branch: 'jh/release-2.0-faster'
1 change: 1 addition & 0 deletions lib/blueprinter-activerecord.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
module BlueprinterActiveRecord
autoload :QueryMethods, 'blueprinter-activerecord/query_methods'
autoload :Preloader, 'blueprinter-activerecord/preloader'
autoload :Preloads, 'blueprinter-activerecord/preloads'
autoload :AddedPreloadsLogger, 'blueprinter-activerecord/added_preloads_logger'
autoload :MissingPreloadsLogger, 'blueprinter-activerecord/missing_preloads_logger'
autoload :PreloadInfo, 'blueprinter-activerecord/preload_info'
Expand Down
2 changes: 1 addition & 1 deletion lib/blueprinter-activerecord/added_preloads_logger.rb
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ def initialize(&log_proc)
def pre_render(object, blueprint, view, options)
if object.is_a?(ActiveRecord::Relation) && object.before_preload_blueprint
from_code = object.before_preload_blueprint
from_blueprint = Preloader.preloads(blueprint, view, model: object.model)
from_blueprint = Preloads.preloads(blueprint, view, model: object.model)
info = PreloadInfo.new(object, from_code, from_blueprint, caller)
@log_proc&.call(info)
end
Expand Down
1 change: 1 addition & 0 deletions lib/blueprinter-activerecord/helpers.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# frozen_string_literal: true

module BlueprinterActiveRecord
# @!visibility private
module Helpers
extend self

Expand Down
2 changes: 1 addition & 1 deletion lib/blueprinter-activerecord/missing_preloads_logger.rb
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ def initialize(&log_proc)
def pre_render(object, blueprint, view, options)
if object.is_a?(ActiveRecord::Relation) && !object.before_preload_blueprint
from_code = extract_preloads object
from_blueprint = Preloader.preloads(blueprint, view, model: object.model)
from_blueprint = Preloads.preloads(blueprint, view, model: object.model)
info = PreloadInfo.new(object, from_code, from_blueprint, caller)
@log_proc&.call(info)
end
Expand Down
100 changes: 23 additions & 77 deletions lib/blueprinter-activerecord/preloader.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,18 @@ module BlueprinterActiveRecord
# A Blueprinter extension to automatically preload a Blueprint view's ActiveRecord associations during render
class Preloader < Blueprinter::Extension
include Helpers
DEFAULT_MAX_RECURSION = 10

attr_reader :use, :auto, :auto_proc

#
# Initialize and configure the extension.
#
# V2 block arg: A Context struct containing the object, the blueprint, the options, and more.
#
# Legacy/V1 block args: The object to render, the blueprint class, the view name, and options.
#
# @param auto [true|false] When true, preload for EVERY ActiveRecord::Relation passed to a Blueprint
# @param use [:preload|:includes] When `auto` is true, use this method (e.g. :preload) for preloading
# @yield [Object, Class, Symbol, Hash] Instead of passing `auto` as a boolean, you may define a block that accepts the object to render, the blueprint class, the view, and options. If the block returns true, auto preloading will take place.
# @yield Instead of passing `auto` as a boolean, you may define a block that returns true when you want auto preloading to happen. See above for the block's args.
#
def initialize(auto: false, use: :preload, &auto_proc)
@auto = auto
Expand All @@ -27,8 +29,23 @@ def initialize(auto: false, use: :preload, &auto_proc)
end
end

# Perform preloading for ActiveRecord::Relation objects (Blueprinter V2).
def around_result(ctx)
obj = ctx.object
if obj.is_a?(ActiveRecord::Relation) && (preload?(ctx) || obj.preload_blueprint_method)
loader = obj.preload_blueprint_method || use
obj.before_preload_blueprint = extract_preloads obj
preloads = Preloads.preloads(ctx.blueprint.class, :default, model: obj.model)
ctx.object = obj.public_send(loader, preloads)
elsif obj.is_a?(ActiveRecord::Base) && preload?(ctx)
preloads = Preloads.preloads(ctx.blueprint.class, :default, model: obj.class)
Preloads.preload_into(obj, preloads)
end
yield ctx
end

#
# Implements the "pre_render" Blueprinter Extension to preload associations from a view.
# Implements the "pre_render" Blueprinter Legacy/V1 Extension to preload associations from a view.
# If auto is true, all ActiveRecord::Relation and ActiveRecord::AssociationRelation objects
# will be preloaded. If auto is false, only queries that have called `.preload_blueprint`
# will be preloaded.
Expand All @@ -41,7 +58,7 @@ def pre_render(object, blueprint, view, options)
if object.is_a?(ActiveRecord::Relation) && !object.loaded?
if object.preload_blueprint_method || auto || auto_proc&.call(object, blueprint, view, options) == true
object.before_preload_blueprint = extract_preloads object
blueprint_preloads = self.class.preloads(blueprint, view, model: object.model)
blueprint_preloads = Preloads.preloads(blueprint, view, model: object.model)
loader = object.preload_blueprint_method || use
object.public_send(loader, blueprint_preloads)
else
Expand All @@ -54,77 +71,6 @@ def pre_render(object, blueprint, view, options)

private

#
# Returns an ActiveRecord preload plan extracted from the Blueprint and view (recursive).
#
# Preloads are found when one of the model's associations matches:
# 1. A Blueprint association name.
# 2. A :preload option on a field or association.
#
# Example:
#
# preloads = BlueprinterActiveRecord::Preloader.preloads(WidgetBlueprint, :extended, model: Widget)
# q = Widget.where(...).order(...).preload(preloads)
#
# @param blueprint [Class] The Blueprint class
# @param view_name [Symbol] Name of the view in blueprint
# @param model [Class|:polymorphic] The ActiveRecord model class that blueprint represents
# @param cycles [Hash<String, Integer>] (internal) Preloading will halt if recursion/cycles gets too high
# @return [Hash] A Hash containing preload/eager_load/etc info for ActiveRecord
#
def self.preloads(blueprint, view_name, model:, cycles: {})
view = blueprint.reflections.fetch(view_name)
preload_vals = view.associations.each_with_object({}) { |(_name, assoc), acc|
# look for a matching association on the model
if (preload = association_preloads(assoc, model, cycles))
acc[assoc.name] = preload
end

# look for a :preload option on the association
if (custom = assoc.options[:preload])
Helpers.merge_values custom, acc
end
}

# look for a :preload options on fields
view.fields.each_with_object(preload_vals) { |(_name, field), acc|
if (custom = field.options[:preload])
Helpers.merge_values custom, acc
end
}
end

def self.association_preloads(assoc, model, cycles)
max_cycles = assoc.options.fetch(:max_recursion, DEFAULT_MAX_RECURSION)
if model == :polymorphic
if assoc.blueprint.is_a? Proc
{}
else
cycles, count = count_cycles(assoc.blueprint, assoc.view, cycles)
count < max_cycles ? preloads(assoc.blueprint, assoc.view, model: model, cycles: cycles) : {}
end
elsif (ref = model.reflections[assoc.name.to_s])
if assoc.blueprint.is_a? Proc
{}
elsif ref.belongs_to? && ref.polymorphic?
cycles, count = count_cycles(assoc.blueprint, assoc.view, cycles)
count < max_cycles ? preloads(assoc.blueprint, assoc.view, model: :polymorphic, cycles: cycles) : {}
else
cycles, count = count_cycles(assoc.blueprint, assoc.view, cycles)
count < max_cycles ? preloads(assoc.blueprint, assoc.view, model: ref.klass, cycles: cycles) : {}
end
end
end

def self.count_cycles(blueprint, view, cycles)
id = "#{blueprint.name || blueprint.inspect}/#{view}"
cycles = cycles.dup
if cycles[id].nil?
cycles[id] = 0
else
cycles[id] += 1
end
return cycles, cycles[id]
end
def preload?(ctx) = auto || auto_proc&.call(ctx) == true
end
end
102 changes: 102 additions & 0 deletions lib/blueprinter-activerecord/preloads.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# frozen_string_literal: true

module BlueprinterActiveRecord
# @!visibility private
module Preloads
DEFAULT_MAX_RECURSION = 10

#
# Returns an ActiveRecord preload plan extracted from the Blueprint and view (recursive).
#
# Preloads are found when one of the model's associations matches:
# 1. A Blueprint association name.
# 2. A :preload option on a field or association.
#
# Example:
#
# preloads = BlueprinterActiveRecord::Preloads.preloads(WidgetBlueprint, :extended, model: Widget)
# q = Widget.where(...).order(...).preload(preloads)
#
# @param blueprint [Class] The Blueprint class
# @param view_name [Symbol] Name of the view in blueprint (for V2 this will always be `:default`)
# @param model [Class|:polymorphic] The ActiveRecord model class that blueprint represents
# @param cycles [Hash<String, Integer>] (internal) Preloading will halt if recursion/cycles gets too high
# @return [Hash] A Hash containing preload/eager_load/etc info for ActiveRecord
#

def self.preloads(blueprint, view_name, model:, cycles: {})
view = blueprint.reflections.fetch(view_name)
preload_vals = view.associations.each_value.each_with_object({}) { |assoc, acc|
assoc_view, assoc_name = v1_or_v2_assoc(assoc)
# look for a matching association on the model
preload = association_preloads(model, assoc_name, blueprint: assoc.blueprint, view: assoc_view, options: assoc.options, cycles:)
acc[assoc_name] = preload if preload

# look for a :preload option on the association
if (custom = assoc.options[:preload])
Helpers.merge_values custom, acc
end
}

# look for a :preload options on fields
view.fields.each_with_object(preload_vals) { |(_name, field), acc|
if (custom = field.options[:preload])
Helpers.merge_values custom, acc
end
}
end

def self.association_preloads(model, name, blueprint:, view:, options:, cycles:)
return {} if blueprint.is_a? Proc

if defined?(::Blueprinter::ViewWrapper) && blueprint.is_a?(::Blueprinter::ViewWrapper)
return association_preloads(model, name, blueprint: blueprint.blueprint, view: blueprint.view_name, options:, cycles:)
end

max_cycles = options.fetch(:max_recursion, DEFAULT_MAX_RECURSION)
if model == :polymorphic
cycles, count = count_cycles(blueprint, view, cycles)
count < max_cycles ? preloads(blueprint, view, model:, cycles:) : {}
elsif (ref = model.reflections[name.to_s])
if ref.belongs_to? && ref.polymorphic?
cycles, count = count_cycles(blueprint, view, cycles)
count < max_cycles ? preloads(blueprint, view, model: :polymorphic, cycles:) : {}
else
cycles, count = count_cycles(blueprint, view, cycles)
count < max_cycles ? preloads(blueprint, view, model: ref.klass, cycles:) : {}
end
end
end

# Preload into an existing record
def self.preload_into(record, preloads)
case ActiveRecord::VERSION::MAJOR
when 7, 8
ActiveRecord::Associations::Preloader.new(records: [record], associations: preloads).call
else
raise "Unsupported ActiveRecord version"
end
end

def self.v1_or_v2_assoc(assoc)
if assoc.respond_to?(:view)
# V1
return assoc.view, assoc.name
else
# V2
return :default, assoc.source
end
end

def self.count_cycles(blueprint, view, cycles)
id = "#{blueprint.name || blueprint.inspect}/#{view}"
cycles = cycles.dup
if cycles[id].nil?
cycles[id] = 0
else
cycles[id] += 1
end
return cycles, cycles[id]
end
end
end
6 changes: 5 additions & 1 deletion lib/blueprinter-activerecord/query_methods.rb
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
# frozen_string_literal: true

module BlueprinterActiveRecord
# Included by ActiveRecord::Relation
module QueryMethods
# Extended by ActiveRecord::Base
module Delegates
# See {BlueprinterActiveRecord::QueryMethods::preload_blueprint}
def preload_blueprint(blueprint = nil, view = :default, use: :preload)
all.preload_blueprint(blueprint, view, use: use)
end
end

# @!visibility private
ACTIONS = %i(preload eager_load includes).freeze

#
Expand Down Expand Up @@ -43,7 +47,7 @@ def preload_blueprint!(blueprint = nil, view = :default, use: :preload)

if blueprint and view
# preload right now
preloads = Preloader.preloads(blueprint, view, model: model)
preloads = Preloads.preloads(blueprint, view, model: model)
public_send(use, preloads)
else
# preload during render
Expand Down
Loading