diff --git a/CHANGELOG.md b/CHANGELOG.md index dbb5fc4..b3a89e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index a892151..6317f4c 100644 --- a/README.md +++ b/README.md @@ -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* @@ -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. diff --git a/lib/blueprinter-activerecord/preloader.rb b/lib/blueprinter-activerecord/preloader.rb index 59832ba..c4aea8f 100644 --- a/lib/blueprinter-activerecord/preloader.rb +++ b/lib/blueprinter-activerecord/preloader.rb @@ -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) @@ -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 diff --git a/lib/blueprinter-activerecord/version.rb b/lib/blueprinter-activerecord/version.rb index 92b6e23..46e96a7 100644 --- a/lib/blueprinter-activerecord/version.rb +++ b/lib/blueprinter-activerecord/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module BlueprinterActiveRecord - VERSION = "1.0.2" + VERSION = "1.1.0" end diff --git a/test/preloads_test.rb b/test/preloads_test.rb index 5bb7bf6..ca41286 100644 --- a/test/preloads_test.rb +++ b/test/preloads_test.rb @@ -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