Skip to content

Commit

Permalink
Merge pull request #19 from procore-oss/annotated-preloads
Browse files Browse the repository at this point in the history
Allow arbitrary annotated preloads on fields and associations
  • Loading branch information
jhollinger authored Jun 11, 2024
2 parents dd86f4e + d025e5e commit 2f16164
Show file tree
Hide file tree
Showing 5 changed files with 102 additions and 32 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
### 1.1.0 (2024-06-10)
- [FEATURE] Ability to annotate a field or association for extra preloads (e.g. `field :category_name, preload: :category`)

### 1.0.2 (2024-05-21)

- [BUGFIX] Fixes a potentially significant performance issue with `auto`. See https://github.com/procore-oss/blueprinter-activerecord/pull/16.
Expand Down
72 changes: 42 additions & 30 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,44 @@ If you'd prefer to use `includes` or `eager_load` rather than `preload`, pass th
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.

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

## Notes on use

### Pass the *query* to render, not query *results*
Expand Down Expand Up @@ -92,41 +130,15 @@ do_something widgets
WidgetBlueprint.render(widgets, view: :extended)
```

### Look out for hidden associations
### Use strict_loading to find hidden associations

*blueprinter-activerecord* may feel magical, but it's not magic. Some associations may be "hidden" and you'll need to preload them the old-fashioned way.
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.

```ruby
# Here's a Blueprint with one association and one field
class WidgetBlueprint < Blueprinter::Base
association :category, blueprint: CategoryBlueprint
field :parts_description
...
end

class Widget < ActiveRecord::Base
belongs_to :category
has_many :parts

# The field is this instance method, and Blueprinter can't see inside it
def parts_description
# I'm calling the "parts" association but no one knows!
parts.map(&:description).join(", ")
end
end

q = Widget.where(...).order(...).
# Since "category" is declared in the Blueprint, it will automatically be preloaded during "render".
# But because "parts" is hidden inside of a method call, we must manually preload it.
preload(:parts).
# catch any other hidden associations
strict_loading

WidgetBlueprint.render(q)
widgets = Widget.where(...).strict_loading
WidgetBlueprint.render(widgets)
```

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 any associations Blueprinter can't see.

## 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.
Expand Down
19 changes: 18 additions & 1 deletion lib/blueprinter-activerecord/preloader.rb
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@ def pre_render(object, blueprint, view, options)
#
# 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, Widget)
Expand All @@ -69,12 +73,25 @@ def pre_render(object, blueprint, view, options)
#
def self.preloads(blueprint, view_name, model=nil)
view = blueprint.reflections.fetch(view_name)
view.associations.each_with_object({}) { |(_name, assoc), acc|
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)
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
end
Expand Down
2 changes: 1 addition & 1 deletion lib/blueprinter-activerecord/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# frozen_string_literal: true

module BlueprinterActiveRecord
VERSION = "1.0.2"
VERSION = "1.1.0"
end
38 changes: 38 additions & 0 deletions test/preloads_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,42 @@ def test_preload_sans_model
battery2: {refurb_plan: {}, fake_assoc: {}},
}, preloads)
end

def test_preload_with_annotated_fields
blueprint = Class.new(Blueprinter::Base) do
association :project, blueprint: ProjectBlueprint
field :category_name, preload: :category do |w|
w.category.name
end
field :refurb_plan, preload: {battery1: :refurb_plan} do |w|
w.battery1&.refurb_plan&.name
end
end

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

def test_preload_with_annotated_associations
blueprint = Class.new(Blueprinter::Base) do
association :project, blueprint: ProjectBlueprint
association :category_name, blueprint: CategoryBlueprint, preload: :category do |w|
w.category.name
end
association :refurb_plan, blueprint: RefurbPlanBlueprint, preload: {battery1: :refurb_plan} do |w|
w.battery1&.refurb_plan&.name
end
end

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

0 comments on commit 2f16164

Please sign in to comment.