Skip to content

Commit

Permalink
If there's a blueprint that renders itself, halt after 10 levels by d…
Browse files Browse the repository at this point in the history
…efault

Signed-off-by: Jordan Hollinger <[email protected]>
  • Loading branch information
jhollinger committed Jun 25, 2024
1 parent 2b39b67 commit 862f390
Show file tree
Hide file tree
Showing 11 changed files with 114 additions and 18 deletions.
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,28 @@ class WidgetBlueprint < Blueprinter::Base
end
```

## Recursive Blueprints

Sometimes a model, and its blueprint, will have recursive associations. Think of a nested Category model:

```ruby
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:

```ruby
association :children, blueprint: CategoryBlueprint, max_recursion: 20
```

## Notes on use

### Pass the *query* to render, not query *results*
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, object.model)
from_blueprint = Preloader.preloads(blueprint, view, model: object.model)
info = PreloadInfo.new(object, from_code, from_blueprint, caller)
@log_proc&.call(info)
end
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, object.model)
from_blueprint = Preloader.preloads(blueprint, view, model: object.model)
info = PreloadInfo.new(object, from_code, from_blueprint, caller)
@log_proc&.call(info)
end
Expand Down
18 changes: 13 additions & 5 deletions lib/blueprinter-activerecord/preloader.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ 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

Expand Down Expand Up @@ -40,7 +41,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, object.model)
blueprint_preloads = self.class.preloads(blueprint, view, options, model: object.model)
loader = object.preload_blueprint_method || use
object.public_send(loader, blueprint_preloads)
else
Expand All @@ -62,22 +63,29 @@ def pre_render(object, blueprint, view, options)
#
# Example:
#
# preloads = BlueprinterActiveRecord::Preloader.preloads(WidgetBlueprint, :extended, Widget)
# 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] The ActiveRecord model class that blueprint represents
# @return [Hash] A Hash containing preload/eager_load/etc info for ActiveRecord
#
def self.preloads(blueprint, view_name, model=nil)
def self.preloads(blueprint, view_name, options = {}, model: nil, recursion_level: 0)
view = blueprint.reflections.fetch(view_name)
preload_vals = view.associations.each_with_object({}) { |(_name, assoc), acc|
# look for a matching association on the model
ref = model ? model.reflections[assoc.name.to_s] : nil
if (ref || model.nil?) && !assoc.blueprint.is_a?(Proc)
ref_model = ref && !(ref.belongs_to? && ref.polymorphic?) ? ref.klass : nil
acc[assoc.name] = preloads(assoc.blueprint, assoc.view, ref_model)
recursive = assoc.blueprint == blueprint && assoc.view == view_name
acc[assoc.name] =
if recursive and recursion_level == options.fetch(:max_recursion, DEFAULT_MAX_RECURSION)
{}
else
recursion_level += 1 if recursive
ref_model = ref && !(ref.belongs_to? && ref.polymorphic?) ? ref.klass : nil
preloads(assoc.blueprint, assoc.view, options, model: ref_model, recursion_level: recursion_level)
end
end

# look for a :preload option on the association
Expand Down
2 changes: 1 addition & 1 deletion lib/blueprinter-activerecord/query_methods.rb
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ def preload_blueprint!(blueprint = nil, view = :default, use: :preload)

if blueprint and view
# preload right now
preloads = Preloader.preloads(blueprint, view, model)
preloads = Preloader.preloads(blueprint, view, model: model)
public_send(use, preloads)
else
# preload during render
Expand Down
2 changes: 1 addition & 1 deletion lib/tasks/blueprinter_activerecord.rake
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ namespace :blueprinter do

model = args[:model].constantize
blueprint = args[:blueprint].constantize
preloads = BlueprinterActiveRecord::Preloader.preloads(blueprint, args[:view].to_sym, model)
preloads = BlueprinterActiveRecord::Preloader.preloads(blueprint, args[:view].to_sym, model: model)
puts pretty preloads
end
end
Expand Down
40 changes: 36 additions & 4 deletions test/nested_render_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ def setup
end
@queries = []
@sub = ActiveSupport::Notifications.subscribe 'sql.active_record' do |_name, _started, _finished, _uid, data|
@queries << data.fetch(:sql)
@queries << [data.fetch(:sql), data.fetch(:type_casted_binds)]
end
@test_customer = customer2
end
Expand All @@ -44,15 +44,15 @@ def test_queries_with_auto
'SELECT "projects".* FROM "projects"',
'SELECT "customers".* FROM "customers" WHERE "customers"."id" IN (?, ?)',
'SELECT "widgets".* FROM "widgets" WHERE "widgets"."project_id" IN (?, ?, ?)',
], @queries
], @queries.map(&:first)
end

def test_queries_for_collection_proxies
ProjectBlueprint.render(@test_customer.projects, view: :extended_plus_with_widgets)
assert_equal [
'SELECT "projects".* FROM "projects" WHERE "projects"."customer_id" = ?',
'SELECT "widgets".* FROM "widgets" WHERE "widgets"."project_id" IN (?, ?)'
], @queries
], @queries.map(&:first)
end

def test_queries_with_auto_and_nested_render_and_manual_preloads
Expand All @@ -74,6 +74,38 @@ def test_queries_with_auto_and_nested_render_and_manual_preloads
'SELECT "widgets".* FROM "widgets" WHERE "widgets"."project_id" IN (?, ?, ?)',
'SELECT "categories".* FROM "categories" WHERE "categories"."id" IN (?, ?)',
'SELECT "customers".* FROM "customers" WHERE "customers"."id" IN (?, ?)',
], @queries
], @queries.map(&:first)
end

def test_preload_with_recursive_association_default_max
cat = Category.create!(name: "A")

cat2 = Category.create!(name: "B", parent_id: cat.id)
cat3 = Category.create!(name: "B", parent_id: cat.id)

cat4 = Category.create!(name: "C", parent_id: cat2.id)
cat5 = Category.create!(name: "C", parent_id: cat2.id)
cat6 = Category.create!(name: "C", parent_id: cat3.id)
cat7 = Category.create!(name: "C", parent_id: cat3.id)

cat8 = Category.create!(name: "D", parent_id: cat4.id)
cat9 = Category.create!(name: "D", parent_id: cat4.id)
cat10 = Category.create!(name: "D", parent_id: cat5.id)
cat11 = Category.create!(name: "D", parent_id: cat5.id)
cat12 = Category.create!(name: "D", parent_id: cat6.id)
cat13 = Category.create!(name: "D", parent_id: cat6.id)
cat14 = Category.create!(name: "D", parent_id: cat7.id)
cat15 = Category.create!(name: "D", parent_id: cat7.id)
@queries.clear

CategoryBlueprint.render(cat, view: :nested)
assert_equal [
%Q|SELECT "categories".* FROM "categories" WHERE "categories"."parent_id" = #{cat.id}|,
%Q|SELECT "categories".* FROM "categories" WHERE "categories"."parent_id" IN (#{cat2.id}, #{cat3.id})|,
%Q|SELECT "categories".* FROM "categories" WHERE "categories"."parent_id" IN (#{cat4.id}, #{cat5.id}, #{cat6.id}, #{cat7.id})|,
%Q|SELECT "categories".* FROM "categories" WHERE "categories"."parent_id" IN (#{cat8.id}, #{cat9.id}, #{cat10.id}, #{cat11.id}, #{cat12.id}, #{cat13.id}, #{cat14.id}, #{cat15.id})|,
], @queries.map { |(sql, binds)|
binds.reduce(sql) { |acc, bind| acc.sub("?", bind.to_s) }
}
end
end
35 changes: 31 additions & 4 deletions test/preloads_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

class PreloadsTest < Minitest::Test
def test_preload_with_model
preloads = BlueprinterActiveRecord::Preloader.preloads(WidgetBlueprint, :extended, Widget)
preloads = BlueprinterActiveRecord::Preloader.preloads(WidgetBlueprint, :extended, model: Widget)
assert_equal({
category: {},
project: {customer: {}},
Expand All @@ -14,7 +14,7 @@ def test_preload_with_model
end

def test_preload_with_model_with_custom_names
preloads = BlueprinterActiveRecord::Preloader.preloads(WidgetBlueprint, :short, Widget)
preloads = BlueprinterActiveRecord::Preloader.preloads(WidgetBlueprint, :short, model: Widget)
assert_equal({
category: {},
project: {customer: {}},
Expand Down Expand Up @@ -45,7 +45,7 @@ def test_preload_with_annotated_fields
end
end

preloads = BlueprinterActiveRecord::Preloader.preloads(blueprint, :default, Widget)
preloads = BlueprinterActiveRecord::Preloader.preloads(blueprint, :default, model: Widget)
assert_equal({
project: {},
category: {},
Expand All @@ -64,11 +64,38 @@ def test_preload_with_annotated_associations
end
end

preloads = BlueprinterActiveRecord::Preloader.preloads(blueprint, :default, Widget)
preloads = BlueprinterActiveRecord::Preloader.preloads(blueprint, :default, model: Widget)
assert_equal({
project: {},
category: {},
battery1: {refurb_plan: {}},
}, preloads)
end

def test_preload_with_recursive_association_default
blueprint = Class.new(Blueprinter::Base) do
association :children, blueprint: self
association :widgets, blueprint: WidgetBlueprint
end

preloads = BlueprinterActiveRecord::Preloader.preloads(blueprint, :default, model: Category)
expected = BlueprinterActiveRecord::Preloader::DEFAULT_MAX_RECURSION.times.
reduce({widgets: {}, children: {}}) { |acc, _|
{widgets: {}, children: acc}
}
assert_equal(expected, preloads)
end

def test_preload_with_recursive_association_custom
blueprint = Class.new(Blueprinter::Base) do
association :children, blueprint: self
association :widgets, blueprint: WidgetBlueprint
end

preloads = BlueprinterActiveRecord::Preloader.preloads(blueprint, :default, {max_recursion: 5}, model: Category)
expected = 5.times.reduce({widgets: {}, children: {}}) { |acc, _|
{widgets: {}, children: acc}
}
assert_equal(expected, preloads)
end
end
3 changes: 3 additions & 0 deletions test/support/active_record_models.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ class Project < ActiveRecord::Base

class Category < ActiveRecord::Base
belongs_to :company
belongs_to :parent, class_name: "Category", optional: true
has_many :children, foreign_key: :parent_id, class_name: "Category", inverse_of: :parent
has_many :widgets
end

class Widget < ActiveRecord::Base
Expand Down
1 change: 1 addition & 0 deletions test/support/active_record_schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ def self.load!

create_table :categories do |t|
t.string :name, null: false
t.integer :parent_id
t.text :description
end

Expand Down
5 changes: 4 additions & 1 deletion test/support/blueprints.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ class CategoryBlueprint < Blueprinter::Base
view :extended do
fields :id, :name, :description
end

view :nested do
association :children, blueprint: CategoryBlueprint, view: :nested
end
end

class RefurbPlanBlueprint < Blueprinter::Base
Expand Down Expand Up @@ -85,4 +89,3 @@ class WidgetBlueprint < Blueprinter::Base
association :project, blueprint: ProjectBlueprint, view: :extended
end
end

0 comments on commit 862f390

Please sign in to comment.