diff --git a/Gemfile b/Gemfile index be635e9..beb03ad 100644 --- a/Gemfile +++ b/Gemfile @@ -10,3 +10,4 @@ gem 'minitest', '~> 6.0' gem 'rake', '~> 13.0' gem 'sqlite3', '~> 2.6' gem 'ostruct', '~> 0.6' +gem 'blueprinter', git: 'https://github.com/procore-oss/blueprinter.git', branch: 'jh/release-2.0-faster' diff --git a/lib/blueprinter-activerecord.rb b/lib/blueprinter-activerecord.rb index 5fc4dd7..544f834 100644 --- a/lib/blueprinter-activerecord.rb +++ b/lib/blueprinter-activerecord.rb @@ -6,6 +6,7 @@ module BlueprinterActiveRecord autoload :QueryMethods, 'blueprinter-activerecord/query_methods' autoload :Preloader, 'blueprinter-activerecord/preloader' + autoload :Preloads, 'blueprinter-activerecord/preloads' autoload :AddedPreloadsLogger, 'blueprinter-activerecord/added_preloads_logger' autoload :MissingPreloadsLogger, 'blueprinter-activerecord/missing_preloads_logger' autoload :PreloadInfo, 'blueprinter-activerecord/preload_info' diff --git a/lib/blueprinter-activerecord/added_preloads_logger.rb b/lib/blueprinter-activerecord/added_preloads_logger.rb index 78556c4..e29d7b7 100644 --- a/lib/blueprinter-activerecord/added_preloads_logger.rb +++ b/lib/blueprinter-activerecord/added_preloads_logger.rb @@ -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, model: object.model) + from_blueprint = Preloads.preloads(blueprint, view, model: object.model) info = PreloadInfo.new(object, from_code, from_blueprint, caller) @log_proc&.call(info) end diff --git a/lib/blueprinter-activerecord/helpers.rb b/lib/blueprinter-activerecord/helpers.rb index 1faa948..773981f 100644 --- a/lib/blueprinter-activerecord/helpers.rb +++ b/lib/blueprinter-activerecord/helpers.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true module BlueprinterActiveRecord + # @!visibility private module Helpers extend self diff --git a/lib/blueprinter-activerecord/missing_preloads_logger.rb b/lib/blueprinter-activerecord/missing_preloads_logger.rb index 2018072..9e41549 100644 --- a/lib/blueprinter-activerecord/missing_preloads_logger.rb +++ b/lib/blueprinter-activerecord/missing_preloads_logger.rb @@ -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, model: object.model) + from_blueprint = Preloads.preloads(blueprint, view, model: object.model) info = PreloadInfo.new(object, from_code, from_blueprint, caller) @log_proc&.call(info) end diff --git a/lib/blueprinter-activerecord/preloader.rb b/lib/blueprinter-activerecord/preloader.rb index 72dca1d..3fea3e8 100644 --- a/lib/blueprinter-activerecord/preloader.rb +++ b/lib/blueprinter-activerecord/preloader.rb @@ -4,16 +4,18 @@ 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 # # Initialize and configure the extension. # + # V2 block arg: A Context struct containing the object, the blueprint, the options, and more. + # + # Legacy/V1 block args: The object to render, the blueprint class, the view name, and options. + # # @param auto [true|false] When true, preload for EVERY ActiveRecord::Relation passed to a Blueprint # @param use [:preload|:includes] When `auto` is true, use this method (e.g. :preload) for preloading - # @yield [Object, Class, Symbol, Hash] Instead of passing `auto` as a boolean, you may define a block that accepts the object to render, the blueprint class, the view, and options. If the block returns true, auto preloading will take place. + # @yield Instead of passing `auto` as a boolean, you may define a block that returns true when you want auto preloading to happen. See above for the block's args. # def initialize(auto: false, use: :preload, &auto_proc) @auto = auto @@ -27,8 +29,23 @@ def initialize(auto: false, use: :preload, &auto_proc) end end + # Perform preloading for ActiveRecord::Relation objects (Blueprinter V2). + def around_result(ctx) + obj = ctx.object + if obj.is_a?(ActiveRecord::Relation) && (preload?(ctx) || obj.preload_blueprint_method) + loader = obj.preload_blueprint_method || use + obj.before_preload_blueprint = extract_preloads obj + preloads = Preloads.preloads(ctx.blueprint.class, :default, model: obj.model) + ctx.object = obj.public_send(loader, preloads) + elsif obj.is_a?(ActiveRecord::Base) && preload?(ctx) + preloads = Preloads.preloads(ctx.blueprint.class, :default, model: obj.class) + Preloads.preload_into(obj, preloads) + end + yield ctx + end + # - # Implements the "pre_render" Blueprinter Extension to preload associations from a view. + # Implements the "pre_render" Blueprinter Legacy/V1 Extension to preload associations from a view. # If auto is true, all ActiveRecord::Relation and ActiveRecord::AssociationRelation objects # will be preloaded. If auto is false, only queries that have called `.preload_blueprint` # will be preloaded. @@ -41,7 +58,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, model: object.model) + blueprint_preloads = Preloads.preloads(blueprint, view, model: object.model) loader = object.preload_blueprint_method || use object.public_send(loader, blueprint_preloads) else @@ -54,77 +71,6 @@ def pre_render(object, blueprint, view, options) private - # - # 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, 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|:polymorphic] The ActiveRecord model class that blueprint represents - # @param cycles [Hash] (internal) Preloading will halt if recursion/cycles gets too high - # @return [Hash] A Hash containing preload/eager_load/etc info for ActiveRecord - # - def self.preloads(blueprint, view_name, model:, cycles: {}) - view = blueprint.reflections.fetch(view_name) - preload_vals = view.associations.each_with_object({}) { |(_name, assoc), acc| - # look for a matching association on the model - if (preload = association_preloads(assoc, model, cycles)) - acc[assoc.name] = preload - 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 - - def self.association_preloads(assoc, model, cycles) - max_cycles = assoc.options.fetch(:max_recursion, DEFAULT_MAX_RECURSION) - if model == :polymorphic - if assoc.blueprint.is_a? Proc - {} - else - cycles, count = count_cycles(assoc.blueprint, assoc.view, cycles) - count < max_cycles ? preloads(assoc.blueprint, assoc.view, model: model, cycles: cycles) : {} - end - elsif (ref = model.reflections[assoc.name.to_s]) - if assoc.blueprint.is_a? Proc - {} - elsif ref.belongs_to? && ref.polymorphic? - cycles, count = count_cycles(assoc.blueprint, assoc.view, cycles) - count < max_cycles ? preloads(assoc.blueprint, assoc.view, model: :polymorphic, cycles: cycles) : {} - else - cycles, count = count_cycles(assoc.blueprint, assoc.view, cycles) - count < max_cycles ? preloads(assoc.blueprint, assoc.view, model: ref.klass, cycles: cycles) : {} - end - end - end - - def self.count_cycles(blueprint, view, cycles) - id = "#{blueprint.name || blueprint.inspect}/#{view}" - cycles = cycles.dup - if cycles[id].nil? - cycles[id] = 0 - else - cycles[id] += 1 - end - return cycles, cycles[id] - end + def preload?(ctx) = auto || auto_proc&.call(ctx) == true end end diff --git a/lib/blueprinter-activerecord/preloads.rb b/lib/blueprinter-activerecord/preloads.rb new file mode 100644 index 0000000..0344029 --- /dev/null +++ b/lib/blueprinter-activerecord/preloads.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +module BlueprinterActiveRecord + # @!visibility private + module Preloads + DEFAULT_MAX_RECURSION = 10 + + # + # 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::Preloads.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 (for V2 this will always be `:default`) + # @param model [Class|:polymorphic] The ActiveRecord model class that blueprint represents + # @param cycles [Hash] (internal) Preloading will halt if recursion/cycles gets too high + # @return [Hash] A Hash containing preload/eager_load/etc info for ActiveRecord + # + + def self.preloads(blueprint, view_name, model:, cycles: {}) + view = blueprint.reflections.fetch(view_name) + preload_vals = view.associations.each_value.each_with_object({}) { |assoc, acc| + assoc_view, assoc_name = v1_or_v2_assoc(assoc) + # look for a matching association on the model + preload = association_preloads(model, assoc_name, blueprint: assoc.blueprint, view: assoc_view, options: assoc.options, cycles:) + acc[assoc_name] = preload if preload + + # 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 + + def self.association_preloads(model, name, blueprint:, view:, options:, cycles:) + return {} if blueprint.is_a? Proc + + if defined?(::Blueprinter::ViewWrapper) && blueprint.is_a?(::Blueprinter::ViewWrapper) + return association_preloads(model, name, blueprint: blueprint.blueprint, view: blueprint.view_name, options:, cycles:) + end + + max_cycles = options.fetch(:max_recursion, DEFAULT_MAX_RECURSION) + if model == :polymorphic + cycles, count = count_cycles(blueprint, view, cycles) + count < max_cycles ? preloads(blueprint, view, model:, cycles:) : {} + elsif (ref = model.reflections[name.to_s]) + if ref.belongs_to? && ref.polymorphic? + cycles, count = count_cycles(blueprint, view, cycles) + count < max_cycles ? preloads(blueprint, view, model: :polymorphic, cycles:) : {} + else + cycles, count = count_cycles(blueprint, view, cycles) + count < max_cycles ? preloads(blueprint, view, model: ref.klass, cycles:) : {} + end + end + end + + # Preload into an existing record + def self.preload_into(record, preloads) + case ActiveRecord::VERSION::MAJOR + when 7, 8 + ActiveRecord::Associations::Preloader.new(records: [record], associations: preloads).call + else + raise "Unsupported ActiveRecord version" + end + end + + def self.v1_or_v2_assoc(assoc) + if assoc.respond_to?(:view) + # V1 + return assoc.view, assoc.name + else + # V2 + return :default, assoc.source + end + end + + def self.count_cycles(blueprint, view, cycles) + id = "#{blueprint.name || blueprint.inspect}/#{view}" + cycles = cycles.dup + if cycles[id].nil? + cycles[id] = 0 + else + cycles[id] += 1 + end + return cycles, cycles[id] + end + end +end diff --git a/lib/blueprinter-activerecord/query_methods.rb b/lib/blueprinter-activerecord/query_methods.rb index f460bbc..c493c10 100644 --- a/lib/blueprinter-activerecord/query_methods.rb +++ b/lib/blueprinter-activerecord/query_methods.rb @@ -1,13 +1,17 @@ # frozen_string_literal: true module BlueprinterActiveRecord + # Included by ActiveRecord::Relation module QueryMethods + # Extended by ActiveRecord::Base module Delegates + # See {BlueprinterActiveRecord::QueryMethods::preload_blueprint} def preload_blueprint(blueprint = nil, view = :default, use: :preload) all.preload_blueprint(blueprint, view, use: use) end end + # @!visibility private ACTIONS = %i(preload eager_load includes).freeze # @@ -43,7 +47,7 @@ def preload_blueprint!(blueprint = nil, view = :default, use: :preload) if blueprint and view # preload right now - preloads = Preloader.preloads(blueprint, view, model: model) + preloads = Preloads.preloads(blueprint, view, model: model) public_send(use, preloads) else # preload during render diff --git a/test/nested_render_v2_test.rb b/test/nested_render_v2_test.rb new file mode 100644 index 0000000..fef12eb --- /dev/null +++ b/test/nested_render_v2_test.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true + +require 'test_helper' + +class NestedRenderV2Test < Minitest::Test + def setup + DatabaseCleaner.start + customer1 = Customer.create!(name: "ACME") + customer2 = Customer.create!(name: "FOO") + project1 = Project.create!(customer_id: customer1.id, name: "Project A") + project2 = Project.create!(customer_id: customer2.id, name: "Project B") + project3 = Project.create!(customer_id: customer2.id, name: "Project C") + category1 = Category.create!(name: "Foo") + category2 = Category.create!(name: "Bar") + ref_plan = RefurbPlan.create!(name: "Plan A") + battery1 = LiIonBattery.create!(num_ions: 100, num_other: 100, refurb_plan_id: ref_plan.id) + battery2 = LeadAcidBattery.create!(num_lead: 100, num_acid: 100) + Widget.create!(customer_id: customer1.id, project_id: project1.id, category_id: category1.id, name: "Widget A", battery1: battery1, battery2: battery2) + Widget.create!(customer_id: customer1.id, project_id: project1.id, category_id: category2.id, name: "Widget B", battery1: battery1) + Widget.create!(customer_id: customer2.id, project_id: project2.id, category_id: category1.id, name: "Widget C", battery1: battery1) + Widget.create!(customer_id: customer2.id, project_id: project3.id, category_id: category1.id, name: "Widget C", battery1: battery1) + @queries = [] + @sub = ActiveSupport::Notifications.subscribe 'sql.active_record' do |_name, _started, _finished, _uid, data| + @queries << [data.fetch(:sql), data.fetch(:type_casted_binds)] + end + @test_customer = customer2 + @project_blueprint = Class.new(ProjectBlueprintV2) do + add BlueprinterActiveRecord::Preloader.new(auto: true) + end + end + + def teardown + DatabaseCleaner.clean + ActiveSupport::Notifications.unsubscribe @sub + @queries.clear + end + + def test_queries_with_auto + @project_blueprint[:extended_plus_with_widgets].render(Project.all.strict_loading).to_hash + assert_equal [ + 'SELECT "projects".* FROM "projects"', + 'SELECT "customers".* FROM "customers" WHERE "customers"."id" IN (?, ?)', + 'SELECT "widgets".* FROM "widgets" WHERE "widgets"."project_id" IN (?, ?, ?)', + ], @queries.map(&:first) + end + + def test_queries_for_collection_proxies + @project_blueprint[:extended_plus_with_widgets].render(@test_customer.projects).to_hash + assert_equal [ + 'SELECT "projects".* FROM "projects" WHERE "projects"."customer_id" = ?', + 'SELECT "widgets".* FROM "widgets" WHERE "widgets"."project_id" IN (?, ?)' + ], @queries.map(&:first) + end + + def test_queries_with_auto_and_nested_render_and_manual_preloads + application_blueprint = Class.new(Blueprinter::V2::Base) do + add BlueprinterActiveRecord::Preloader.new(auto: true) + end + + category_blueprint = Class.new(CategoryBlueprintV2) do + add BlueprinterActiveRecord::Preloader.new(auto: true) + end + + customer_blueprint = Class.new(CustomerBlueprintV2) do + add BlueprinterActiveRecord::Preloader.new(auto: true) + end + + widget_blueprint = Class.new(application_blueprint) do + association :category, category_blueprint + end + + project_blueprint = Class.new(application_blueprint) do + association :customer, customer_blueprint + association(:widgets, [widget_blueprint]) { |object, _ctx| object.widgets } + end + + q = Project.all.preload(widgets: :category).strict_loading + project_blueprint.render(q).to_hash + assert_equal [ + 'SELECT "projects".* FROM "projects"', + '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.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 + + category_blueprint = Class.new(Blueprinter::V2::Base) do + add BlueprinterActiveRecord::Preloader.new(auto: true) + + fields :id, :name + + view :nested do + association :children, [self] + end + end + + category_blueprint[:nested].render(cat).to_hash + 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 diff --git a/test/preloader_extension_v2_test.rb b/test/preloader_extension_v2_test.rb new file mode 100644 index 0000000..dfafdaa --- /dev/null +++ b/test/preloader_extension_v2_test.rb @@ -0,0 +1,200 @@ +# frozen_string_literal: true + +require 'test_helper' + +class PreloaderExtensionV2Test < Minitest::Test + def setup + DatabaseCleaner.start + @customer = Customer.create!(name: "ACME") + project = Project.create!(customer_id: @customer.id, name: "Project A") + category = Category.create!(name: "Foo") + ref_plan = RefurbPlan.create!(name: "Plan A") + battery1 = LiIonBattery.create!(num_ions: 100, num_other: 100, refurb_plan_id: ref_plan.id) + battery2 = LeadAcidBattery.create!(num_lead: 100, num_acid: 100) + Widget.create!(customer_id: @customer.id, project_id: project.id, category_id: category.id, name: "Widget A", battery1: battery1, battery2: battery2) + Widget.create!(customer_id: @customer.id, project_id: project.id, category_id: category.id, name: "Widget B", battery1: battery1) + Widget.create!(customer_id: @customer.id, project_id: project.id, category_id: category.id, name: "Widget C", battery1: battery1) + @widget_blueprint = Class.new(WidgetBlueprintV2) do + add BlueprinterActiveRecord::Preloader.new + end + end + + def teardown + DatabaseCleaner.clean + end + + def test_without + q = Widget. + where("name <> ?", "Widget C"). + order(:name) + widgets = @widget_blueprint[:extended].render(q).to_hash + + assert_equal ["Widget A", 'Widget B'], widgets.map { |w| w[:name] } + assert_equal ["Project A"], widgets.map { |w| w.dig(:project, :name) }.uniq + assert_equal ["ACME"], widgets.map { |w| w.dig(:project, :customer, :name) }.uniq + assert_equal ["Foo"], widgets.map { |w| w.dig(:category, :name) }.uniq + assert_equal ["Plan A"], widgets.map { |w| [w.dig(:battery1, :refurb_plan, :name), w.dig(:battery1, :refurb_plan, :name)] }.flatten.compact.uniq + end + + def test_blueprinter_default + q = Widget. + where("name <> ?", "Widget C"). + order(:name). + preload_blueprint. + strict_loading + widgets = @widget_blueprint[:extended].render(q).to_hash + + assert_equal ["Widget A", 'Widget B'], widgets.map { |w| w[:name] } + assert_equal ["Project A"], widgets.map { |w| w.dig(:project, :name) }.uniq + assert_equal ["ACME"], widgets.map { |w| w.dig(:project, :customer, :name) }.uniq + assert_equal ["Foo"], widgets.map { |w| w.dig(:category, :name) }.uniq + assert_equal ["Plan A"], widgets.map { |w| [w.dig(:battery1, :refurb_plan, :name), w.dig(:battery1, :refurb_plan, :name)] }.flatten.compact.uniq + end + + def test_blueprinter_preload + q = Widget. + where("name <> ?", "Widget C"). + order(:name). + preload_blueprint(use: :preload). + strict_loading + widgets = @widget_blueprint[:extended].render(q).to_hash + + assert_equal ["Widget A", 'Widget B'], widgets.map { |w| w[:name] } + assert_equal ["Project A"], widgets.map { |w| w.dig(:project, :name) }.uniq + assert_equal ["ACME"], widgets.map { |w| w.dig(:project, :customer, :name) }.uniq + assert_equal ["Foo"], widgets.map { |w| w.dig(:category, :name) }.uniq + assert_equal ["Plan A"], widgets.map { |w| [w.dig(:battery1, :refurb_plan, :name), w.dig(:battery1, :refurb_plan, :name)] }.flatten.compact.uniq + end + + def test_blueprinter_includes + q = Widget. + where("name <> ?", "Widget C"). + order(:name). + preload_blueprint(use: :includes). + strict_loading + widgets = @widget_blueprint[:extended].render(q).to_hash + + assert_equal ["Widget A", 'Widget B'], widgets.map { |w| w[:name] } + assert_equal ["Project A"], widgets.map { |w| w.dig(:project, :name) }.uniq + assert_equal ["ACME"], widgets.map { |w| w.dig(:project, :customer, :name) }.uniq + assert_equal ["Foo"], widgets.map { |w| w.dig(:category, :name) }.uniq + assert_equal ["Plan A"], widgets.map { |w| [w.dig(:battery1, :refurb_plan, :name), w.dig(:battery1, :refurb_plan, :name)] }.flatten.compact.uniq + end + + def test_blueprinter_eager_load + q = Widget. + where("widgets.name <> ?", "Widget C"). + order(:name). + preload_blueprint(use: :eager_load). + strict_loading + widgets = @widget_blueprint[:no_power].render(q).to_hash + + assert_equal ["Widget A", 'Widget B'], widgets.map { |w| w[:name] } + assert_equal ["Project A"], widgets.map { |w| w.dig(:project, :name) }.uniq + assert_equal ["ACME"], widgets.map { |w| w.dig(:project, :customer, :name) }.uniq + assert_equal ["Foo"], widgets.map { |w| w.dig(:category, :name) }.uniq + end + + def test_blueprinter_preload_now + q = Widget. + where("widgets.name <> ?", "Widget C"). + order(:name). + preload_blueprint(@widget_blueprint[:extended]). + strict_loading + + assert_equal [{:battery1=>{:fake_assoc=>{}, :refurb_plan=>{}}, :battery2=>{:fake_assoc=>{}, :refurb_plan=>{}}, :category=>{}, :project=>{:customer=>{}}}], q.values[:preload] + end + + def test_blueprinter_preload_now_with_existing_preloads + q = Widget. + where("widgets.name <> ?", "Widget C"). + order(:name). + preload({battery1: :refurb_plan}). + preload_blueprint(@widget_blueprint, :no_power). + strict_loading + + assert_equal({:battery1=>{:refurb_plan=>{}}, :category=>{}, :project=>{:customer=>{}}}, BlueprinterActiveRecord::Helpers.merge_values(q.values[:preload])) + end + + def test_auto_preload + ext = BlueprinterActiveRecord::Preloader.new(auto: true) + q = Widget. + where("name <> ?", "Widget C"). + order(:name). + strict_loading + ctx = Blueprinter::V2::Context::Result.new(@widget_blueprint[:extended].new, [], {}, q, :json) + ext.around_result(ctx) { |ctx| q = ctx.object } + + assert ext.auto + assert_equal :preload, ext.use + assert_equal [{:battery1=>{:fake_assoc=>{}, :refurb_plan=>{}}, :battery2=>{:fake_assoc=>{}, :refurb_plan=>{}}, :category=>{}, :project=>{:customer=>{}}}], q.values[:preload] + end + + def test_auto_preload_with_existing_preloads + ext = BlueprinterActiveRecord::Preloader.new(auto: true) + q = Widget. + where("name <> ?", "Widget C"). + order(:name). + preload({battery1: :refurb_plan}). + strict_loading + ctx = Blueprinter::V2::Context::Result.new(@widget_blueprint[:no_power].new, [], {}, q, :json) + ext.around_result(ctx) { |ctx| q = ctx.object } + + assert ext.auto + assert_equal :preload, ext.use + assert_equal({:battery1=>{:refurb_plan=>{}}, :category=>{}, :project=>{:customer=>{}}}, BlueprinterActiveRecord::Helpers.merge_values(q.values[:preload])) + end + + def test_auto_preload_with_association_relation + ext = BlueprinterActiveRecord::Preloader.new(auto: true) + q = @customer.widgets.order(:name).strict_loading + ctx = Blueprinter::V2::Context::Result.new(@widget_blueprint[:extended].new, [], {}, q, :json) + ext.around_result(ctx) { |ctx| q = ctx.object } + + assert ext.auto + assert_equal :preload, ext.use + assert_equal [{:battery1=>{:fake_assoc=>{}, :refurb_plan=>{}}, :battery2=>{:fake_assoc=>{}, :refurb_plan=>{}}, :category=>{}, :project=>{:customer=>{}}}], q.values[:preload] + end + + def test_auto_preload_with_block_true + ext = BlueprinterActiveRecord::Preloader.new { |object| true } + q = Widget. + where("name <> ?", "Widget C"). + order(:name). + strict_loading + ctx = Blueprinter::V2::Context::Result.new(@widget_blueprint[:extended].new, [], {}, q, :json) + ext.around_result(ctx) { |ctx| q = ctx.object } + + refute_nil ext.auto_proc + assert_equal :preload, ext.use + assert_equal [{:battery1=>{:fake_assoc=>{}, :refurb_plan=>{}}, :battery2=>{:fake_assoc=>{}, :refurb_plan=>{}}, :category=>{}, :project=>{:customer=>{}}}], q.values[:preload] + end + + def test_auto_preload_with_block_false + ext = BlueprinterActiveRecord::Preloader.new { |object| false } + q = Widget. + where("name <> ?", "Widget C"). + order(:name). + strict_loading + ctx = Blueprinter::V2::Context::Result.new(@widget_blueprint[:extended].new, [], {}, q, :json) + ext.around_result(ctx) { |ctx| q = ctx.object } + + refute_nil ext.auto_proc + assert_equal :preload, ext.use + assert_nil q.values[:preload] + end + + def test_auto_includes + ext = BlueprinterActiveRecord::Preloader.new(auto: true, use: :includes) + q = Widget. + where("name <> ?", "Widget C"). + order(:name). + strict_loading + ctx = Blueprinter::V2::Context::Result.new(@widget_blueprint[:extended].new, [], {}, q, :json) + ext.around_result(ctx) { |ctx| q = ctx.object } + + assert ext.auto + assert_equal :includes, ext.use + assert_equal [{:battery1=>{:fake_assoc=>{}, :refurb_plan=>{}}, :battery2=>{:fake_assoc=>{}, :refurb_plan=>{}}, :category=>{}, :project=>{:customer=>{}}}], q.values[:includes] + end +end diff --git a/test/preloads_test.rb b/test/preloads/api_v1_test.rb similarity index 62% rename from test/preloads_test.rb rename to test/preloads/api_v1_test.rb index 12d2599..4ca5a59 100644 --- a/test/preloads_test.rb +++ b/test/preloads/api_v1_test.rb @@ -2,9 +2,9 @@ require 'test_helper' -class PreloadsTest < Minitest::Test +class PreloadsApiV1Test < Minitest::Test def test_preload_with_model - preloads = BlueprinterActiveRecord::Preloader.preloads(WidgetBlueprint, :extended, model: Widget) + preloads = BlueprinterActiveRecord::Preloads.preloads(WidgetBlueprint, :extended, model: Widget) assert_equal({ category: {}, project: {customer: {}}, @@ -14,7 +14,7 @@ def test_preload_with_model end def test_preload_with_model_with_custom_names - preloads = BlueprinterActiveRecord::Preloader.preloads(WidgetBlueprint, :short, model: Widget) + preloads = BlueprinterActiveRecord::Preloads.preloads(WidgetBlueprint, :short, model: Widget) assert_equal({ category: {}, project: {customer: {}}, @@ -24,7 +24,7 @@ def test_preload_with_model_with_custom_names end def test_preload_with_polymorphic_model - preloads = BlueprinterActiveRecord::Preloader.preloads(WidgetBlueprint, :extended, model: :polymorphic) + preloads = BlueprinterActiveRecord::Preloads.preloads(WidgetBlueprint, :extended, model: :polymorphic) assert_equal({:battery1=>{:fake_assoc=>{}, :fake_assoc2=>{}, :refurb_plan=>{}}, :battery2=>{:fake_assoc=>{}, :fake_assoc2=>{}, :refurb_plan=>{}}, :category=>{}, :parts=>{}, :project=>{:customer=>{}}}, preloads) end @@ -39,7 +39,7 @@ def test_preload_with_annotated_fields end end - preloads = BlueprinterActiveRecord::Preloader.preloads(blueprint, :default, model: Widget) + preloads = BlueprinterActiveRecord::Preloads.preloads(blueprint, :default, model: Widget) assert_equal({ project: {}, category: {}, @@ -58,7 +58,7 @@ def test_preload_with_annotated_associations end end - preloads = BlueprinterActiveRecord::Preloader.preloads(blueprint, :default, model: Widget) + preloads = BlueprinterActiveRecord::Preloads.preloads(blueprint, :default, model: Widget) assert_equal({ project: {}, category: {}, @@ -72,8 +72,8 @@ def test_preload_with_recursive_blueprint_default_max association :widgets, blueprint: WidgetBlueprint end - preloads = BlueprinterActiveRecord::Preloader.preloads(blueprint, :default, model: Category) - expected = BlueprinterActiveRecord::Preloader::DEFAULT_MAX_RECURSION.times. + preloads = BlueprinterActiveRecord::Preloads.preloads(blueprint, :default, model: Category) + expected = BlueprinterActiveRecord::Preloads::DEFAULT_MAX_RECURSION.times. reduce({widgets: {}, children: {}}) { |acc, _| {widgets: {}, children: acc} } @@ -86,7 +86,7 @@ def test_preload_with_recursive_blueprint_custom_max association :widgets, blueprint: WidgetBlueprint end - preloads = BlueprinterActiveRecord::Preloader.preloads(blueprint, :default, model: Category) + preloads = BlueprinterActiveRecord::Preloads.preloads(blueprint, :default, model: Category) expected = 5.times.reduce({widgets: {}, children: {}}) { |acc, _| {widgets: {}, children: acc} } @@ -94,8 +94,8 @@ def test_preload_with_recursive_blueprint_custom_max end def test_preload_with_cyclic_blueprints_default_max - preloads = BlueprinterActiveRecord::Preloader.preloads(CategoryBlueprint, :cyclic, model: Category) - expected = BlueprinterActiveRecord::Preloader::DEFAULT_MAX_RECURSION.times. + preloads = BlueprinterActiveRecord::Preloads.preloads(CategoryBlueprint, :cyclic, model: Category) + expected = BlueprinterActiveRecord::Preloads::DEFAULT_MAX_RECURSION.times. reduce({widgets: {}}) { |acc, _| {widgets: {category: acc}} } @@ -103,18 +103,34 @@ def test_preload_with_cyclic_blueprints_default_max end def test_halts_on_dynamic_blueprint - preloads = BlueprinterActiveRecord::Preloader.preloads(WidgetBlueprint, :dynamic, model: Widget) + preloads = BlueprinterActiveRecord::Preloads.preloads(WidgetBlueprint, :dynamic, model: Widget) assert_equal({category: {}}, preloads) end + def test_v2_interop + widget_blueprint = Class.new(Blueprinter::Base) do + association :category, blueprint: CategoryBlueprintV2 + end + preloads = BlueprinterActiveRecord::Preloads.preloads(widget_blueprint, :default, model: Widget) + assert_equal({ category: {} }, preloads) + end + + def test_v2_interop_with_view_option + widget_blueprint = Class.new(Blueprinter::Base) do + association :category, blueprint: CategoryBlueprintV2, view: :extended + end + preloads = BlueprinterActiveRecord::Preloads.preloads(widget_blueprint, :default, model: Widget) + assert_equal({ category: {} }, preloads) + end + def test_cycle_detection1 - cycles, count = BlueprinterActiveRecord::Preloader.count_cycles(CategoryBlueprint, :default, {}) + cycles, count = BlueprinterActiveRecord::Preloads.count_cycles(CategoryBlueprint, :default, {}) assert_equal({"CategoryBlueprint/default" => 0}, cycles) assert_equal 0, count end def test_cycle_detection2 - cycles, count = BlueprinterActiveRecord::Preloader.count_cycles(CategoryBlueprint, :default, { + cycles, count = BlueprinterActiveRecord::Preloads.count_cycles(CategoryBlueprint, :default, { "CategoryBlueprint/default" => 0, }) assert_equal({"CategoryBlueprint/default" => 1}, cycles) @@ -122,7 +138,7 @@ def test_cycle_detection2 end def test_cycle_detection3 - cycles, count = BlueprinterActiveRecord::Preloader.count_cycles(WidgetBlueprint, :default, { + cycles, count = BlueprinterActiveRecord::Preloads.count_cycles(WidgetBlueprint, :default, { "WidgetBlueprint/default" => 9, "CategoryBlueprint/foo" => 8, }) diff --git a/test/preloads/api_v2_test.rb b/test/preloads/api_v2_test.rb new file mode 100644 index 0000000..af98768 --- /dev/null +++ b/test/preloads/api_v2_test.rb @@ -0,0 +1,143 @@ +# frozen_string_literal: true + +require 'test_helper' + +class PreloadsApiV2Test < Minitest::Test + def test_preload_with_model + preloads = BlueprinterActiveRecord::Preloads.preloads(WidgetBlueprintV2[:extended], :default, model: Widget) + assert_equal({ + category: {}, + project: { customer: {} }, + battery1: { refurb_plan: {}, fake_assoc: {} }, + battery2: { refurb_plan: {}, fake_assoc: {} }, + }, preloads) + end + + def test_preload_with_model_with_custom_names + preloads = BlueprinterActiveRecord::Preloads.preloads(WidgetBlueprintV2[:short], :default, model: Widget) + assert_equal({ + category: {}, + project: { customer: {} }, + battery1: { refurb_plan: {}, fake_assoc: {} }, + battery2: { refurb_plan: {}, fake_assoc: {} }, + }, preloads) + end + + def test_preload_with_polymorphic_model + preloads = BlueprinterActiveRecord::Preloads.preloads(WidgetBlueprintV2[:extended], :default, model: :polymorphic) + assert_equal({:battery1=>{:fake_assoc=>{}, :refurb_plan=>{}}, :battery2=>{:fake_assoc=>{}, :refurb_plan=>{}}, :category=>{}, :parts=>{}, :project=>{:customer=>{}}}, preloads) + end + + def test_preload_with_annotated_fields + blueprint = Class.new(Blueprinter::V2::Base) do + association :project, ProjectBlueprintV2 + 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::Preloads.preloads(blueprint, :default, model: Widget) + assert_equal({ + project: {}, + category: {}, + battery1: {refurb_plan: {}}, + }, preloads) + end + + def test_preload_with_annotated_associations + blueprint = Class.new(Blueprinter::V2::Base) do + association :project, ProjectBlueprintV2 + association :category_name, CategoryBlueprintV2, preload: :category do |w| + w.category.name + end + association :refurb_plan, RefurbPlanBlueprintV2, preload: {battery1: :refurb_plan} do |w| + w.battery1&.refurb_plan&.name + end + end + + preloads = BlueprinterActiveRecord::Preloads.preloads(blueprint, :default, model: Widget) + assert_equal({ + project: {}, + category: {}, + battery1: {refurb_plan: {}}, + }, preloads) + end + + def test_preload_with_recursive_blueprint_default_max + blueprint = Class.new(Blueprinter::V2::Base) do + association :children, [self] + association :widgets, [WidgetBlueprintV2] + end + + preloads = BlueprinterActiveRecord::Preloads.preloads(blueprint, :default, model: Category) + expected = BlueprinterActiveRecord::Preloads::DEFAULT_MAX_RECURSION.times. + reduce({widgets: {}, children: {}}) { |acc, _| + {widgets: {}, children: acc} + } + assert_equal(expected, preloads) + end + + def test_preload_with_recursive_blueprint_custom_max + blueprint = Class.new(Blueprinter::V2::Base) do + association :children, [self], max_recursion: 5 + association :widgets, [WidgetBlueprintV2] + end + + preloads = BlueprinterActiveRecord::Preloads.preloads(blueprint, :default, model: Category) + expected = 5.times.reduce({widgets: {}, children: {}}) { |acc, _| + {widgets: {}, children: acc} + } + assert_equal(expected, preloads) + end + + def test_preload_with_cyclic_blueprints_default_max + preloads = BlueprinterActiveRecord::Preloads.preloads(CategoryBlueprintV2[:cyclic], :default, model: Category) + expected = BlueprinterActiveRecord::Preloads::DEFAULT_MAX_RECURSION.times. + reduce({widgets: {}}) { |acc, _| + {widgets: {category: acc}} + } + assert_equal(expected, preloads) + end + + def test_v1_interop + widget_blueprint = Class.new(Blueprinter::V2::Base) do + association :category, [CategoryBlueprint] + end + preloads = BlueprinterActiveRecord::Preloads.preloads(widget_blueprint, :default, model: Widget) + assert_equal({ category: {} }, preloads) + end + + def test_v1_interop_view_wrapper + widget_blueprint = Class.new(Blueprinter::V2::Base) do + association :category, [CategoryBlueprint[:extended]] + end + preloads = BlueprinterActiveRecord::Preloads.preloads(widget_blueprint, :default, model: Widget) + assert_equal({ category: {} }, preloads) + end + + def test_cycle_detection1 + cycles, count = BlueprinterActiveRecord::Preloads.count_cycles(CategoryBlueprintV2[:extended], :default, {}) + assert_equal({ "CategoryBlueprintV2.extended/default" => 0 }, cycles) + assert_equal 0, count + end + + def test_cycle_detection2 + cycles, count = BlueprinterActiveRecord::Preloads.count_cycles(CategoryBlueprintV2[:extended], :default, { + "CategoryBlueprintV2.extended/default" => 0, + }) + assert_equal({ "CategoryBlueprintV2.extended/default" => 1 }, cycles) + assert_equal 1, count + end + + def test_cycle_detection3 + cycles, count = BlueprinterActiveRecord::Preloads.count_cycles(WidgetBlueprintV2, :default, { + "WidgetBlueprintV2/default" => 9, + "CategoryBlueprintV2.foo/default" => 8, + }) + assert_equal({"WidgetBlueprintV2/default" => 10, "CategoryBlueprintV2.foo/default" => 8}, cycles) + assert_equal 10, count + end +end diff --git a/test/support/blueprints.rb b/test/support/v1_blueprints.rb similarity index 100% rename from test/support/blueprints.rb rename to test/support/v1_blueprints.rb diff --git a/test/support/v2_blueprints.rb b/test/support/v2_blueprints.rb new file mode 100644 index 0000000..a06b0cb --- /dev/null +++ b/test/support/v2_blueprints.rb @@ -0,0 +1,98 @@ +class CustomerBlueprintV2 < Blueprinter::V2::Base + fields :id, :name +end + +class WidgetBlueprintV2 < Blueprinter::V2::Base +end + +class ProjectBlueprintV2 < Blueprinter::V2::Base + fields :id, :customer_id, :name + + view :extended do + fields :id, :name + association :customer, CustomerBlueprintV2 + end + + view :extended_plus do + use :extended + end + + view :extended_plus_with_widgets do + use :extended_plus + association :widgets, [WidgetBlueprintV2] + end +end + +class CategoryBlueprintV2 < Blueprinter::V2::Base + fields :id, :name + + view :extended do + fields :id, :name, :description + end + + view :nested do + association :children, [CategoryBlueprintV2[:nested]] + end + + view :cyclic do + association :widgets, [WidgetBlueprintV2[:cyclic]] + end +end + +class RefurbPlanBlueprintV2 < Blueprinter::V2::Base + fields :name, :description +end + +class FakeAssocBlueprintV2 < Blueprinter::V2::Base + fields :name +end + +class BatteryBlueprintV2 < Blueprinter::V2::Base + field :description do |battery, _opts| + case battery + when LiIonBattery + "#{battery.num_ions} parts Li ions, #{battery.num_other} parts other" + when LeadAcidBattery + "#{battery.num_lead} parts lead, #{battery.num_acid} parts acid" + end + end + association :refurb_plan, RefurbPlanBlueprintV2 + association :fake_assoc, FakeAssocBlueprintV2 +end + +class PartBlueprintV2 < Blueprinter::V2::Base + fields :name +end + +class WidgetBlueprintV2 < Blueprinter::V2::Base + fields :id, :name, :price + + view :extended do + fields :id, :name, :price, :description + association :parts, [PartBlueprintV2] + association :category, CategoryBlueprintV2[:extended] + association :project, ProjectBlueprintV2[:extended] + association :battery1, BatteryBlueprintV2 + association :battery2, BatteryBlueprintV2 + end + + view :short do + fields :id, :name, :price, :description + association :parts, [PartBlueprintV2] + association :category, CategoryBlueprintV2[:extended] + association :project, ProjectBlueprintV2[:extended] + association :bat1, BatteryBlueprintV2, source: :battery1 + association :bat2, BatteryBlueprintV2, source: :battery2 + end + + view :no_power do + fields :id, :name, :price, :description + association :parts, [PartBlueprintV2] + association :category, CategoryBlueprintV2[:extended] + association :project, ProjectBlueprintV2[:extended] + end + + view :cyclic do + association :category, CategoryBlueprintV2[:cyclic] + end +end