blueprinter-activerecord
blueprinter-activerecord is a blueprinter extension to help you easily preload the associations from your Blueprints, N levels deep. It also provides logging extensions so you can measure how effective the primary extension is/will be.
Installation
Add blueprinter-activerecord
to your Gemfile and enable the extension using one of the configurations below.
Configurations
Automatic mode
In automatic mode, every query (ActiveRecord::Relation
) passed to a Blueprint will automatically be preloaded during render.
Blueprinter.configure do |config|
config.extensions << BlueprinterActiveRecord::Preloader.new(auto: true)
end
# Preloading will always happen during render
widgets = Widget.where(...).order(...)
json = WidgetBlueprint.render(widgets)
If you'd prefer to use includes
rather than preload
, pass use: :includes
to the initializer.
Dynamic mode
In dynamic mode, each query passed to a Blueprint is evaluated by the block. If it returns true
, the query will be preloaded during render.
Blueprinter.configure do |config|
config.extensions << BlueprinterActiveRecord::Preloader.new do |q, blueprint, view, options|
# examine q, q.model, blueprint, view, or options and return true or false
end
end
# If the above block returns true for q (widgets), preloading will happen during render
widgets = Widget.where(...).order(...)
json = WidgetBlueprint.render(widgets)
If you'd prefer to use includes
rather than preload
, pass use: :includes
to the initializer.
Manual mode
In manual mode, nothing happens automatically; you'll need to opt individual queries into preloading.
Blueprinter.configure do |config|
config.extensions << BlueprinterActiveRecord::Preloader.new
end
The preload_blueprint
method is used to opt queries in:
q = Widget.
where(...).
order(...).
preload_blueprint
# preloading happens during "render"
json = WidgetBlueprint.render(q, view: :extended)
If you'd prefer to use includes
or eager_load
rather than preload
, pass the use
option:
preload_blueprint(use: :includes)
Annotations
Sometimes a field in your blueprint is a method or block. This extension can't "see" into methods or blocks, meaning it can't preload any associations inside. In these cases, annotate your blueprint so the extension knows what to preload.
# Here is a model with some instance methods
class Widget < ActiveRecord::Base
belongs_to :category
belongs_to :project
has_many :parts
# Blueprinter can't see what this method is calling
def parts_description
# I'm calling the "parts" association, but the caller won't know!
parts.map(&:description).join(", ")
end
end
# Here's a Blueprint with one association, two annotated fields, and one annotated association
class WidgetBlueprint < Blueprinter::Base
# This association will be automatically preloaded
association :category, blueprint: CategoryBlueprint
# Blueprinter can't see the "parts" association being used here, so we annotate it
field :parts_description, preload: :parts
# Your annotations can be as complex as needed
field :owner_address, preload: {project: [:company, {owner: :address}]} do |widget|
widget.project.owner ? widget.project.owner.address.to_s : widget.project.company.address
end
# You can annotate association blocks, too. "parts" is preloaded automatically.
association :parts, blueprint: PartBlueprint, preload: :draft_parts do |widget|
widget.parts + widget.draft_parts
end
end
Recursive Blueprints
Sometimes a model, and its blueprint, will have recursive associations. Think of a nested Category model:
class Category < ApplicationRecord
belongs_to :parent, class_name: "Category", optional: true
has_many :children, foreign_key: :parent_id, class_name: "Category", inverse_of: :parent
end
class CategoryBlueprint < Blueprinter::Base
field :name
association :children, blueprint: CategoryBlueprint
end
For these kinds of recursive blueprints, the extension will preload up to 10 levels deep by default. If this isn't enough, you can increase it:
association :children, blueprint: CategoryBlueprint, max_recursion: 20
Notes on use
Pass the query to render, not query results
If the query runs before being passed to render
, it's too late for preloading to happen.
widgets = Widget.where(...)
widgets.each { |widget| do_something wiget }
# too late to preload b/c the query already ran :(
WidgetBlueprint.render(widgets, view: :extended)
But sometimes you have no choice. In those cases, manually call preload_blueprint
and pass it the Blueprint/view. Then preloading will happen as soon as the query runs.
widgets = Widget.
where(...).
preload_blueprint(WidgetBlueprint, :extended)
# preloading will happen here, because it knows which Blueprint/view to look at
widgets.each { |widget| do_something wiget }
WidgetBlueprint.render(widgets, view: :extended)
Also works for ActiveRecord::Associations::CollectionProxy
project = Project.find(...)
WidgetBlueprint.render(project.widgets, view: :extended)
Use strict_loading to find hidden associations
Rails 6.1 added support for strict_loading
. Depending on your configuration, it will either raise exceptions or log warnings if a query triggers any lazy loading. Very useful for catching "hidden" associations.
widgets = Widget.where(...).strict_loading
WidgetBlueprint.render(widgets)
Logging
There are two different logging extensions. You can use them together or separately to measure how much the Preloder extension is, or can, help your application.
Missing Preloads Logger
This extension is useful for measuring how helpful BlueprinterActiveRecord::Preloader
will be for your application. It can be used with or without Preloader
. Any Blueprint-rendered queries not caught by the Preloader
extension will be caught by this logger.
Blueprinter.configure do |config|
# Preloader (optional) may be in in manual or dynamic mode
config.extensions << BlueprinterActiveRecord::Preloader.new
# Catches any Blueprint-rendered queries that aren't caught by Preloader
config.extensions << BlueprinterActiveRecord::MissingPreloadsLogger.new do |info|
Rails.logger.info({
event: "missing_preloads",
root_model: info.query.model.name,
sql: info.query.to_sql,
missing: info.found.map { |x| x.join " > " },
percent_missing: info.percent_found,
total: info.num_existing + info.found.size,
visible: info.visible.size,
trace: info.trace,
}.to_json)
end
end
Added Preloads Logger
This extension measures how many missing preloads are being found & fixed by the preloader. Any query caught by this extension won't end up in MissingPreloadsLogger
.
Blueprinter.configure do |config|
# Preloader (required) may be in any mode
config.extensions << BlueprinterActiveRecord::Preloader.new
# Catches any queries found by Preloader
config.extensions << BlueprinterActiveRecord::AddedPreloadsLogger.new do |info|
Rails.logger.info({
event: "added_preloads",
root_model: info.query.model.name,
sql: info.query.to_sql,
added: info.found.map { |x| x.join " > " },
percent_added: info.percent_found,
total: info.num_existing + info.found.size,
visible: info.visible.size,
trace: info.trace,
}.to_json)
end
end
Rake task
Curious what exactly preload_blueprint
is going to preload? There's a rake task to pretty-print the whole tree. Pass it the Blueprint, view, and ActiveRecord model:
bundle exec rake blueprinter:activerecord:preloads[WidgetBlueprint,extended,Widget]
{
:customer => {
:contacts => {},
:address => {},
},
:parts => {},
}
Testing
bundle install
bundle exec appraisal install
bundle exec appraisal rake test