From 1103fd517062126bb720457deefdc486dd3276f8 Mon Sep 17 00:00:00 2001 From: Jordan Hollinger Date: Fri, 7 Jun 2024 09:13:07 -0400 Subject: [PATCH 1/3] Allow arbitrary annotated preloads on fields and associations Signed-off-by: Jordan Hollinger --- README.md | 74 ++++++++++++++--------- lib/blueprinter-activerecord/preloader.rb | 19 +++++- test/preloads_test.rb | 38 ++++++++++++ 3 files changed, 100 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index a892151..a66bea5 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,46 @@ If you'd prefer to use `includes` or `eager_load` rather than `preload`, pass th preload_blueprint(use: :includes) ``` +## Annotations + +Some associations may be "hidden" inside methods or field blocks, requiring you to annotate your Blueprints. + +```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 + + # Or this one + def owner_address + project.owner ? project.owner.address.to_s : project.company.address + end +end + +# Here's a Blueprint with one association, two annotated fields, and one annotated association +class WidgetBlueprint < Blueprinter::Base + 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}]} + + # You can annotate association blocks, too + 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 +132,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/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 From d5f0dfc0abf5d7424a0a931175401ea547db8edd Mon Sep 17 00:00:00 2001 From: Jordan Hollinger Date: Mon, 10 Jun 2024 12:23:00 -0400 Subject: [PATCH 2/3] Update README.md Signed-off-by: Jordan Hollinger --- README.md | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index a66bea5..6317f4c 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ If you'd prefer to use `includes` or `eager_load` rather than `preload`, pass th ## Annotations -Some associations may be "hidden" inside methods or field blocks, requiring you to annotate your Blueprints. +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 @@ -80,24 +80,22 @@ class Widget < ActiveRecord::Base # I'm calling the "parts" association, but the caller won't know! parts.map(&:description).join(", ") end - - # Or this one - def owner_address - project.owner ? project.owner.address.to_s : project.company.address - 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}]} + 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 + # 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 From d025e5e171a5ac147deaee1156afa1fa23482330 Mon Sep 17 00:00:00 2001 From: Jordan Hollinger Date: Fri, 7 Jun 2024 10:28:19 -0400 Subject: [PATCH 3/3] Version 1.1.0 Signed-off-by: Jordan Hollinger --- CHANGELOG.md | 3 +++ lib/blueprinter-activerecord/version.rb | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) 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/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