From 863a278d1323f950d0fe35ad825d99e1e78413f5 Mon Sep 17 00:00:00 2001 From: Thomas von Deyen Date: Thu, 8 Jan 2026 16:44:48 +0100 Subject: [PATCH 1/4] perf: Add ElementPreloader Introduce a new service that preloads element trees efficiently, eliminating N+1 queries for nested elements at any depth. Add alchemy_element_preloads hook to RelatableResource concern. Batch load picture descriptions in Picture This eliminates N+1 queries when loading picture descriptions in the element editor. The generic preloading mechanism allows any related object to define custom preloading by implementing alchemy_element_preloads. --- alchemy_cms.gemspec | 1 + .../alchemy/admin/elements_controller.rb | 27 +- app/models/alchemy/picture.rb | 27 +- .../concerns/alchemy/relatable_resource.rb | 15 + app/services/alchemy/element_preloader.rb | 116 +++++++ spec/models/alchemy/picture_spec.rb | 108 ++++++ spec/rails_helper.rb | 5 + .../alchemy/element_preloader_spec.rb | 319 ++++++++++++++++++ 8 files changed, 597 insertions(+), 21 deletions(-) create mode 100644 app/services/alchemy/element_preloader.rb create mode 100644 spec/services/alchemy/element_preloader_spec.rb diff --git a/alchemy_cms.gemspec b/alchemy_cms.gemspec index 328ad05cb7..379df6ad90 100644 --- a/alchemy_cms.gemspec +++ b/alchemy_cms.gemspec @@ -70,6 +70,7 @@ Gem::Specification.new do |gem| gem.add_development_dependency "selenium-webdriver", ["~> 4.10"] gem.add_development_dependency "webmock", ["~> 3.3"] gem.add_development_dependency "shoulda-matchers", "~> 7.0" + gem.add_development_dependency "db-query-matchers", "~> 0.12" gem.add_development_dependency "timecop", ["~> 0.9"] gem.post_install_message = <<~MSG diff --git a/app/controllers/alchemy/admin/elements_controller.rb b/app/controllers/alchemy/admin/elements_controller.rb index 2ea14a733b..f40169d95a 100644 --- a/app/controllers/alchemy/admin/elements_controller.rb +++ b/app/controllers/alchemy/admin/elements_controller.rb @@ -12,9 +12,13 @@ class ElementsController < Alchemy::Admin::BaseController authorize_resource class: Alchemy::Element def index - elements = @page_version.elements.order(:position).includes(*element_includes) - @elements = elements.not_nested.unfixed - @fixed_elements = elements.not_nested.fixed + root_elements = @page_version.elements.order(:position).not_nested + preloaded = Alchemy::ElementPreloader + .new(elements: root_elements, language: @page.language) + .call + + @elements = preloaded.reject(&:fixed?) + @fixed_elements = preloaded.select(&:fixed?) end def new @@ -161,23 +165,6 @@ def collapse_nested_elements_ids(element) ids end - def element_includes - [ - { - ingredients: :related_object - }, - :tags, - { - all_nested_elements: [ - { - ingredients: :related_object - }, - :tags - ] - } - ] - end - def load_element @element = Element.find(params[:id]) end diff --git a/app/models/alchemy/picture.rb b/app/models/alchemy/picture.rb index 800e9b0d7c..69c46186da 100644 --- a/app/models/alchemy/picture.rb +++ b/app/models/alchemy/picture.rb @@ -133,6 +133,23 @@ def ransackable_scopes(_auth_object = nil) def file_formats(scope = all) Alchemy.storage_adapter.file_formats(name, scope:) end + + # Preload descriptions for element editor display + # + # @param pictures [Array] Collection of pictures to preload for + # @param language [Alchemy::Language] Current language for descriptions + def alchemy_element_preloads(pictures, language:) + return if pictures.blank? || language.nil? + + picture_ids = pictures.map(&:id).uniq + descriptions = Alchemy::PictureDescription + .where(picture_id: picture_ids, language: language) + .index_by(&:picture_id) + + pictures.each do |picture| + picture.instance_variable_set(:@preloaded_description, descriptions[picture.id]) + end + end end # Instance methods @@ -183,8 +200,16 @@ def update_name_and_tag_list!(params) end # Returns the picture description for a given language. + # Uses preloaded description if available (set by ElementPreloader) + # + # @param language [Language] The language to get description for + # @return [String, nil] The description text or nil def description_for(language) - descriptions.find_by(language: language)&.text + if defined?(@preloaded_description) + (@preloaded_description&.language_id == language&.id) ? @preloaded_description&.text : nil + else + descriptions.find_by(language: language)&.text + end end # Returns an uri escaped name. diff --git a/app/models/concerns/alchemy/relatable_resource.rb b/app/models/concerns/alchemy/relatable_resource.rb index cd487d4dc0..5cbfa2c7b6 100644 --- a/app/models/concerns/alchemy/relatable_resource.rb +++ b/app/models/concerns/alchemy/relatable_resource.rb @@ -2,6 +2,21 @@ module Alchemy module RelatableResource extend ActiveSupport::Concern + class_methods do + # Preload associations for element editor display + # + # Override this method in models that need custom preloading + # when displayed in the element editor (e.g., preloading + # language-specific descriptions). + # + # @param records [Array] Collection of records to preload for + # @param language [Alchemy::Language] Current language context + def alchemy_element_preloads(records, language:) + # Default implementation does nothing + # Override in subclasses that need preloading + end + end + included do scope :deletable, -> do where( diff --git a/app/services/alchemy/element_preloader.rb b/app/services/alchemy/element_preloader.rb new file mode 100644 index 0000000000..679de7cc66 --- /dev/null +++ b/app/services/alchemy/element_preloader.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +module Alchemy + # Preloads element trees with all associations and nested elements + # + # This service efficiently loads element trees to avoid N+1 queries. + # It recursively preloads all nested elements to unlimited depth. + # + # @example Preload elements for a page version + # preloader = Alchemy::ElementPreloader.new(elements: root_elements) + # preloaded_elements = preloader.call + # + class ElementPreloader + # @param elements [ActiveRecord::Relation] Root elements to preload + # @param language [Language, nil] Language for related object preloading (optional) + def initialize(elements:, language: nil) + @elements = elements + @language = language + end + + # Preloads and returns the element tree with all associations loaded + # + # @return [Array] Elements with preloaded nested elements + def call + return [] if elements.blank? + + # Load root elements with their immediate associations + root_elements = elements.includes(*element_includes).to_a + return root_elements if root_elements.empty? + + # Collect all element IDs and load all descendants + page_version_id = root_elements.first.page_version_id + all_elements = load_all_elements(page_version_id, root_elements) + + # Build parent -> children lookup and populate associations + populate_nested_associations(all_elements) + + # Preload related objects if language is provided + preload_related_objects(root_elements) if language + + root_elements + end + + private + + attr_reader :elements, :language + + # Load all elements for the page version and preload their associations + def load_all_elements(page_version_id, root_elements) + # Load all elements that belong to this page version (including nested) + # Ordering is handled in populate_nested_associations + all_elements = Element + .where(page_version_id: page_version_id) + .includes(*element_includes) + .index_by(&:id) + + # Replace root elements with preloaded versions from the hash + root_elements.map! { |e| all_elements[e.id] || e } + + all_elements + end + + # Populate the all_nested_elements association for each element + def populate_nested_associations(elements_by_id) + # Group elements by parent_id + elements_by_parent = elements_by_id.values.group_by(&:parent_element_id) + + elements_by_id.each_value do |element| + children = elements_by_parent[element.id] || [] + # Position is scoped by [page_version_id, fixed, parent_element_id] + # Sort unfixed elements first, then fixed, each group by position + children = children.sort_by { |e| [e.fixed? ? 1 : 0, e.position] } + + # Manually set the association target + element.association(:all_nested_elements).target = children + element.association(:all_nested_elements).loaded! + end + end + + # Associations to preload for element rendering + def element_includes + [ + {ingredients: :related_object}, + :tags + ] + end + + # Preload related objects for all ingredients in elements + # Allows related objects to preload their associations (e.g., picture descriptions) + def preload_related_objects(root_elements) + related_objects_by_class = collect_related_objects(root_elements) + return if related_objects_by_class.empty? + + related_objects_by_class.each do |klass, objects| + if klass.respond_to?(:alchemy_element_preloads) + klass.alchemy_element_preloads(objects, language: language) + end + end + end + + # Collect all related objects from element tree, grouped by class + def collect_related_objects(elements, collected = Hash.new { |h, k| h[k] = [] }) + elements.each do |element| + element.ingredients.each do |ingredient| + if ingredient.related_object + collected[ingredient.related_object.class] << ingredient.related_object + end + end + if element.association(:all_nested_elements).loaded? + collect_related_objects(element.all_nested_elements, collected) + end + end + collected + end + end +end diff --git a/spec/models/alchemy/picture_spec.rb b/spec/models/alchemy/picture_spec.rb index 6a6f394732..3c38094313 100644 --- a/spec/models/alchemy/picture_spec.rb +++ b/spec/models/alchemy/picture_spec.rb @@ -320,6 +320,114 @@ module Alchemy context "without a description for the given language" do it { is_expected.to be_nil } end + + context "with preloaded description" do + let!(:description) do + Alchemy::PictureDescription.create!( + picture: picture, + language: language, + text: "Preloaded description" + ) + end + + before do + picture.instance_variable_set(:@preloaded_description, description) + end + + it "returns the preloaded description text" do + expect(picture.description_for(language)).to eq("Preloaded description") + end + + it "does not query the database" do + expect(picture.descriptions).not_to receive(:find_by) + picture.description_for(language) + end + end + + context "with preloaded description for different language" do + let(:other_language) { create(:alchemy_language, :german) } + let!(:description) do + Alchemy::PictureDescription.create!( + picture: picture, + language: other_language, + text: "German description" + ) + end + + before do + picture.instance_variable_set(:@preloaded_description, description) + end + + it "returns nil when querying for a different language" do + expect(picture.description_for(language)).to be_nil + end + end + + context "with nil preloaded description" do + before do + picture.instance_variable_set(:@preloaded_description, nil) + end + + it "returns nil" do + expect(picture.description_for(language)).to be_nil + end + end + end + + describe ".alchemy_element_preloads" do + let(:language) { create(:alchemy_language) } + + context "with pictures that have descriptions" do + let!(:picture1) { create(:alchemy_picture) } + let!(:picture2) { create(:alchemy_picture) } + let!(:description1) do + Alchemy::PictureDescription.create!(picture: picture1, language: language, text: "Description 1") + end + let!(:description2) do + Alchemy::PictureDescription.create!(picture: picture2, language: language, text: "Description 2") + end + + it "preloads descriptions for all pictures" do + described_class.alchemy_element_preloads([picture1, picture2], language: language) + + expect(picture1.instance_variable_get(:@preloaded_description)).to eq(description1) + expect(picture2.instance_variable_get(:@preloaded_description)).to eq(description2) + end + + it "uses only one query for all descriptions" do + query_count = 0 + counter = ->(*, _) { query_count += 1 } + ActiveSupport::Notifications.subscribed(counter, "sql.active_record") do + described_class.alchemy_element_preloads([picture1, picture2], language: language) + end + + expect(query_count).to eq(1) + end + end + + context "with pictures without descriptions" do + let!(:picture) { create(:alchemy_picture) } + + it "sets nil for pictures without descriptions" do + described_class.alchemy_element_preloads([picture], language: language) + + expect(picture.instance_variable_get(:@preloaded_description)).to be_nil + end + end + + context "with empty pictures array" do + it "returns early without errors" do + expect { described_class.alchemy_element_preloads([], language: language) }.not_to raise_error + end + end + + context "with nil language" do + let!(:picture) { create(:alchemy_picture) } + + it "returns early without errors" do + expect { described_class.alchemy_element_preloads([picture], language: nil) }.not_to raise_error + end + end end describe "#restricted?" do diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 51b624cf69..6a69a418e4 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -14,6 +14,7 @@ require "rspec-activemodel-mocks" require "rspec/rails" require "shoulda-matchers" +require "db_query_matchers" require "factory_bot" require "view_component/test_helpers" require "webmock" @@ -64,6 +65,10 @@ end end +DBQueryMatchers.configure do |config| + config.schemaless = true +end + RSpec.configure do |config| config.infer_spec_type_from_file_location! config.include ActiveSupport::Testing::TimeHelpers diff --git a/spec/services/alchemy/element_preloader_spec.rb b/spec/services/alchemy/element_preloader_spec.rb new file mode 100644 index 0000000000..74d502664a --- /dev/null +++ b/spec/services/alchemy/element_preloader_spec.rb @@ -0,0 +1,319 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Alchemy::ElementPreloader do + let(:page_version) { create(:alchemy_page_version) } + + describe "#call" do + context "with no elements" do + it "returns empty array" do + preloader = described_class.new(elements: Alchemy::Element.none) + expect(preloader.call).to eq([]) + end + end + + context "with flat elements (no nesting)" do + let!(:element1) { create(:alchemy_element, page_version: page_version, position: 1) } + let!(:element2) { create(:alchemy_element, page_version: page_version, position: 2) } + + subject do + elements = page_version.elements.not_nested.order(:position) + described_class.new(elements: elements).call + end + + it "returns all root elements" do + expect(subject).to eq([element1, element2]) + end + + it "preloads all_nested_elements association" do + expect(subject.first.association(:all_nested_elements)).to be_loaded + end + + it "preloads ingredients association" do + expect(subject.first.association(:ingredients)).to be_loaded + end + + it "preloads tags association" do + expect(subject.first.association(:tags)).to be_loaded + end + end + + context "with nested elements (2 levels)" do + let!(:slider) { create(:alchemy_element, :with_nestable_elements, page_version: page_version, autogenerate_nested_elements: false) } + let!(:slide1) { create(:alchemy_element, name: "slide", page_version: page_version, parent_element: slider, position: 1) } + let!(:slide2) { create(:alchemy_element, name: "slide", page_version: page_version, parent_element: slider, position: 2) } + + subject do + elements = page_version.elements.not_nested.order(:position) + described_class.new(elements: elements).call + end + + it "returns only root elements" do + expect(subject).to eq([slider]) + end + + it "preloads nested elements" do + expect(subject.first.association(:all_nested_elements)).to be_loaded + expect(subject.first.all_nested_elements).to contain_exactly(slide1, slide2) + end + + it "preserves position order in nested elements" do + expect(subject.first.all_nested_elements).to eq([slide1, slide2]) + end + + it "orders unfixed elements before fixed elements, each by position" do + # Position is scoped by [page_version_id, fixed, parent_element_id] + # Create fixed elements with their own position sequence + fixed1 = create(:alchemy_element, :fixed, name: "slide", page_version: page_version, parent_element: slider, position: 1) + fixed2 = create(:alchemy_element, :fixed, name: "slide", page_version: page_version, parent_element: slider, position: 2) + + elements = page_version.elements.not_nested.order(:position) + result = described_class.new(elements: elements).call + + # Unfixed elements (slide1, slide2) should come before fixed elements (fixed1, fixed2) + expect(result.first.all_nested_elements).to eq([slide1, slide2, fixed1, fixed2]) + end + + it "preloads associations on nested elements" do + nested = subject.first.all_nested_elements.first + expect(nested.association(:ingredients)).to be_loaded + expect(nested.association(:tags)).to be_loaded + end + end + + context "with deeply nested elements (3 levels)" do + # We'll use slider with nested elements to simulate depth + let!(:slider) { create(:alchemy_element, :with_nestable_elements, page_version: page_version, autogenerate_nested_elements: false) } + let!(:slide) { create(:alchemy_element, name: "slide", page_version: page_version, parent_element: slider) } + + # Create a nested element inside (model allows it even if config doesn't) + let!(:deeply_nested) do + create(:alchemy_element, name: "article", page_version: page_version, parent_element: slide) + end + + subject do + elements = page_version.elements.not_nested.order(:position) + described_class.new(elements: elements).call + end + + it "preloads all three levels" do + root = subject.first + expect(root.association(:all_nested_elements)).to be_loaded + + level2 = root.all_nested_elements.first + expect(level2).to eq(slide) + expect(level2.association(:all_nested_elements)).to be_loaded + + level3 = level2.all_nested_elements.first + expect(level3).to eq(deeply_nested) + expect(level3.association(:all_nested_elements)).to be_loaded + end + end + + context "with ingredients and related objects" do + let!(:picture) { create(:alchemy_picture) } + let!(:element) do + create(:alchemy_element, :with_ingredients, name: "all_you_can_eat", page_version: page_version) + end + + before do + # Find the picture ingredient and set its related object + picture_ingredient = element.ingredients.find { |i| i.role == "picture" } + picture_ingredient&.update!(related_object: picture) + end + + subject do + elements = page_version.elements.not_nested.order(:position) + described_class.new(elements: elements).call + end + + it "preloads ingredients" do + expect(subject.first.association(:ingredients)).to be_loaded + end + + it "preloads related_object on ingredients" do + picture_ingredient = subject.first.ingredients.find { |i| i.role == "picture" } + expect(picture_ingredient.association(:related_object)).to be_loaded + end + end + + context "with mixed fixed and unfixed elements" do + let!(:regular_element) { create(:alchemy_element, page_version: page_version, fixed: false) } + let!(:fixed_element) { create(:alchemy_element, :fixed, page_version: page_version) } + + it "preloads both fixed and unfixed elements when given all" do + elements = page_version.elements.not_nested.order(:position) + result = described_class.new(elements: elements).call + + expect(result).to contain_exactly(regular_element, fixed_element) + result.each do |elem| + expect(elem.association(:all_nested_elements)).to be_loaded + end + end + + it "preloads only unfixed elements when filtered" do + elements = page_version.elements.not_nested.unfixed.order(:position) + result = described_class.new(elements: elements).call + + expect(result).to eq([regular_element]) + end + end + + context "query efficiency" do + let!(:slider1) { create(:alchemy_element, :with_nestable_elements, page_version: page_version, autogenerate_nested_elements: false) } + let!(:slider2) { create(:alchemy_element, :with_nestable_elements, page_version: page_version, autogenerate_nested_elements: false) } + let!(:slides1) do + Array.new(3) { |i| create(:alchemy_element, name: "slide", page_version: page_version, parent_element: slider1, position: i) } + end + let!(:slides2) do + Array.new(3) { |i| create(:alchemy_element, name: "slide", page_version: page_version, parent_element: slider2, position: i) } + end + + it "loads all nested elements without N+1 queries" do + elements = page_version.elements.not_nested.order(:position) + + # Queries are bounded regardless of element count (8 elements created) + # Root elements, all elements, ingredients, related objects, tags + # No language passed, so no description preloading + expect { + result = described_class.new(elements: elements).call + + # Access all nested elements to ensure they're loaded + result.each do |root| + root.all_nested_elements.each do |nested| + nested.ingredients.to_a + nested.all_nested_elements.to_a + end + end + }.to make_database_queries(count: 7) + end + end + end + + describe "related object preloading" do + let(:language) { page_version.page.language } + + context "without language parameter" do + let!(:picture) { create(:alchemy_picture) } + let!(:element) do + create(:alchemy_element, :with_ingredients, name: "all_you_can_eat", page_version: page_version) + end + + before do + picture_ingredient = element.ingredients.find { |i| i.role == "picture" } + picture_ingredient&.update!(related_object: picture) + end + + it "does not preload related objects" do + elements = page_version.elements.not_nested.order(:position) + preloaded = described_class.new(elements: elements).call + + picture_ingredient = preloaded.first.ingredients.find { |i| i.role == "picture" } + loaded_picture = picture_ingredient.related_object + + expect(loaded_picture.instance_variable_defined?(:@preloaded_description)).to be false + end + end + + context "with language parameter" do + let!(:picture) { create(:alchemy_picture) } + let!(:element) do + create(:alchemy_element, :with_ingredients, name: "all_you_can_eat", page_version: page_version) + end + let!(:description) do + Alchemy::PictureDescription.create!(picture: picture, language: language, text: "Test description") + end + + before do + picture_ingredient = element.ingredients.find { |i| i.role == "picture" } + picture_ingredient&.update!(related_object: picture) + end + + it "calls alchemy_element_preloads on Picture class" do + elements = page_version.elements.not_nested.order(:position) + + expect(Alchemy::Picture).to receive(:alchemy_element_preloads).with( + array_including(picture), + language: language + ) + + described_class.new(elements: elements, language: language).call + end + + it "preloads picture descriptions" do + elements = page_version.elements.not_nested.order(:position) + preloaded = described_class.new(elements: elements, language: language).call + + picture_ingredient = preloaded.first.ingredients.find { |i| i.role == "picture" } + loaded_picture = picture_ingredient.related_object + + expect(loaded_picture.instance_variable_defined?(:@preloaded_description)).to be true + expect(loaded_picture.description_for(language)).to eq("Test description") + end + + it "avoids N+1 queries for picture descriptions" do + # Create multiple pictures with descriptions + pictures = Array.new(3) { create(:alchemy_picture) } + pictures.each do |pic| + Alchemy::PictureDescription.create!(picture: pic, language: language, text: "Desc for #{pic.id}") + end + + # Create multiple elements with picture ingredients + pictures.each do |pic| + el = create(:alchemy_element, :with_ingredients, name: "all_you_can_eat", page_version: page_version) + picture_ingredient = el.ingredients.find { |i| i.role == "picture" } + picture_ingredient&.update!(related_object: pic) + end + + elements = page_version.elements.not_nested.order(:position) + + # Expected queries: + # Root elements (2x from includes), all elements, ingredients (2x), + # related objects (2x), tags (2x), picture descriptions + # No additional queries when accessing descriptions (preloaded) + expect { + preloaded = described_class.new(elements: elements, language: language).call + + # Access all picture descriptions + preloaded.each do |el| + el.ingredients.each do |ing| + if ing.respond_to?(:picture) && ing.picture + ing.picture.description_for(language) + end + end + end + }.to make_database_queries(count: 10) + end + end + + context "with nested elements containing pictures" do + let!(:picture) { create(:alchemy_picture) } + let!(:slider) { create(:alchemy_element, :with_nestable_elements, page_version: page_version, autogenerate_nested_elements: false) } + let!(:slide) do + create(:alchemy_element, :with_ingredients, name: "slide", page_version: page_version, parent_element: slider) + end + let!(:description) do + Alchemy::PictureDescription.create!(picture: picture, language: language, text: "Nested description") + end + + before do + picture_ingredient = slide.ingredients.find { |i| i.respond_to?(:picture) } + picture_ingredient&.update!(related_object: picture) + end + + it "preloads pictures from nested elements" do + elements = page_version.elements.not_nested.order(:position) + preloaded = described_class.new(elements: elements, language: language).call + + nested_element = preloaded.first.all_nested_elements.first + picture_ingredient = nested_element.ingredients.find { |i| i.respond_to?(:picture) } + + next unless picture_ingredient&.related_object + + loaded_picture = picture_ingredient.related_object + expect(loaded_picture.instance_variable_defined?(:@preloaded_description)).to be true + end + end + end +end From 7fbf75b75af750128439084efb926e9b335d1ea0 Mon Sep 17 00:00:00 2001 From: Thomas von Deyen Date: Thu, 8 Jan 2026 19:33:32 +0100 Subject: [PATCH 2/4] perf: Optimize nested_element_ids_to_collapse Replace recursive N+1 query pattern with single-query --- .../alchemy/admin/elements_controller.rb | 34 ++++++++++++++----- .../alchemy/admin/elements_controller_spec.rb | 13 +++++++ 2 files changed, 39 insertions(+), 8 deletions(-) diff --git a/app/controllers/alchemy/admin/elements_controller.rb b/app/controllers/alchemy/admin/elements_controller.rb index f40169d95a..f077a59548 100644 --- a/app/controllers/alchemy/admin/elements_controller.rb +++ b/app/controllers/alchemy/admin/elements_controller.rb @@ -123,12 +123,12 @@ def order def collapse # We do not want to trigger the touch callback or any validations @element.update_columns(folded: true) - # Collapse all nested elements - nested_elements_ids = collapse_nested_elements_ids(@element) - Alchemy::Element.where(id: nested_elements_ids).update_all(folded: true) + # Collapse all nested elements (except compact ones which stay as-is) + nested_element_ids = nested_element_ids_to_collapse(@element) + Alchemy::Element.where(id: nested_element_ids).update_all(folded: true) render json: { - nestedElementIds: nested_elements_ids, + nestedElementIds: nested_element_ids, title: Alchemy.t(@element.folded? ? :show_element_content : :hide_element_content) } end @@ -156,12 +156,30 @@ def load_page_and_version @page = @page_version.page end - def collapse_nested_elements_ids(element) + # Collects IDs of nested elements that should be collapsed. + # Skips compact elements (they retain their fold state). + # Optimized to use a single query instead of N queries for N levels. + def nested_element_ids_to_collapse(element) + all_elements = Element + .where(page_version_id: element.page_version_id) + .select(:id, :parent_element_id, :folded, :name) + .to_a + + children_by_parent = all_elements.group_by(&:parent_element_id) + ids = [] - element.all_nested_elements.includes(:all_nested_elements).reject(&:compact?).each do |nested_element| - ids.push nested_element.id if nested_element.expanded? - ids.concat collapse_nested_elements_ids(nested_element) if nested_element.all_nested_elements.reject(&:compact?).any? + stack = [element.id] + + while stack.any? + current_id = stack.pop + children = children_by_parent[current_id] || [] + + children.reject(&:compact?).each do |child| + ids << child.id if child.expanded? + stack << child.id + end end + ids end diff --git a/spec/controllers/alchemy/admin/elements_controller_spec.rb b/spec/controllers/alchemy/admin/elements_controller_spec.rb index 29e88969bf..e4756b54b6 100644 --- a/spec/controllers/alchemy/admin/elements_controller_spec.rb +++ b/spec/controllers/alchemy/admin/elements_controller_spec.rb @@ -309,6 +309,19 @@ module Alchemy "title" => "Show content of this element." }) end + + it "uses bounded queries regardless of nesting depth" do + query_count = 0 + counter = ->(*, _) { query_count += 1 } + + ActiveSupport::Notifications.subscribed(counter, "sql.active_record") do + subject + end + + # Should use a small bounded number of queries (includes auth, session, etc.): + # Key point: query count doesn't grow with nesting depth + expect(query_count).to be <= 10 + end end end From 0b7f26de04b63f5d9e4898ac6522747a3bf55ad7 Mon Sep 17 00:00:00 2001 From: Thomas von Deyen Date: Fri, 6 Feb 2026 13:08:32 +0100 Subject: [PATCH 3/4] perf: Optimize richtext_ingredients_ids to use preloaded associations When all_nested_elements is already loaded (via ElementTreePreloader), use the preloaded data instead of querying the database. This avoids N+1 queries when re-initializing TinyMCE editors in the element editor. --- .../alchemy/element/element_ingredients.rb | 14 +++++++++++--- spec/models/alchemy/element_ingredients_spec.rb | 16 ++++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/app/models/alchemy/element/element_ingredients.rb b/app/models/alchemy/element/element_ingredients.rb index 19ae00282e..e403b5d5e3 100644 --- a/app/models/alchemy/element/element_ingredients.rb +++ b/app/models/alchemy/element/element_ingredients.rb @@ -75,11 +75,19 @@ def ingredient_definition_for(role) # This is used to re-initialize the TinyMCE editor in the element editor. # def richtext_ingredients_ids - ids = ingredients.select(&:has_tinymce?).collect(&:id) - expanded_nested_elements = nested_elements.expanded + ids = ingredients.filter_map { |i| i.id if i.has_tinymce? } + + # Use preloaded association if available, otherwise query + expanded_nested_elements = if association(:all_nested_elements).loaded? + all_nested_elements.select(&:expanded?) + else + nested_elements.expanded + end + if expanded_nested_elements.present? - ids += expanded_nested_elements.collect(&:richtext_ingredients_ids) + ids += expanded_nested_elements.map(&:richtext_ingredients_ids) end + ids.flatten end diff --git a/spec/models/alchemy/element_ingredients_spec.rb b/spec/models/alchemy/element_ingredients_spec.rb index 8efa61ec74..2e17916d48 100644 --- a/spec/models/alchemy/element_ingredients_spec.rb +++ b/spec/models/alchemy/element_ingredients_spec.rb @@ -190,6 +190,22 @@ nested_element_2.ingredient_ids ) end + + context "when all_nested_elements is preloaded" do + subject do + Alchemy::Element.preload(:all_nested_elements, ingredients: :related_object) + .find(element.id) + .richtext_ingredients_ids + end + + it "includes all richtext ingredients from all expanded descendent elements" do + is_expected.to eq( + element.ingredient_ids + + nested_element_1.ingredient_ids + + nested_element_2.ingredient_ids + ) + end + end end end end From 07eec5a13338fe52c7cd15023ada9a8b34e93582 Mon Sep 17 00:00:00 2001 From: Thomas von Deyen Date: Fri, 6 Feb 2026 13:26:36 +0100 Subject: [PATCH 4/4] perf: Preload picture thumbs to avoid N+1 queries When rendering element editors, each picture's thumbnail_url was triggering an individual query to find the thumb by signature. Now thumbs are preloaded in batch via the ElementTreePreloader's call to Picture.preload_for_element_editor. --- app/models/alchemy/picture.rb | 7 ++++- app/models/alchemy/storage_adapter.rb | 1 + .../alchemy/storage_adapter/active_storage.rb | 9 +++++++ .../alchemy/storage_adapter/dragonfly.rb | 9 +++++++ spec/models/alchemy/picture_spec.rb | 26 ++++++++++++++----- .../alchemy/element_preloader_spec.rb | 10 ++++--- 6 files changed, 51 insertions(+), 11 deletions(-) diff --git a/app/models/alchemy/picture.rb b/app/models/alchemy/picture.rb index 69c46186da..7b5a1fe2b4 100644 --- a/app/models/alchemy/picture.rb +++ b/app/models/alchemy/picture.rb @@ -134,7 +134,7 @@ def file_formats(scope = all) Alchemy.storage_adapter.file_formats(name, scope:) end - # Preload descriptions for element editor display + # Preload associations for element editor display # # @param pictures [Array] Collection of pictures to preload for # @param language [Alchemy::Language] Current language for descriptions @@ -142,6 +142,8 @@ def alchemy_element_preloads(pictures, language:) return if pictures.blank? || language.nil? picture_ids = pictures.map(&:id).uniq + + # Preload descriptions descriptions = Alchemy::PictureDescription .where(picture_id: picture_ids, language: language) .index_by(&:picture_id) @@ -149,6 +151,9 @@ def alchemy_element_preloads(pictures, language:) pictures.each do |picture| picture.instance_variable_set(:@preloaded_description, descriptions[picture.id]) end + + # Preload storage-specific associations to avoid N+1 when rendering thumbnails + Alchemy.storage_adapter.preload_picture_associations(pictures) end end diff --git a/app/models/alchemy/storage_adapter.rb b/app/models/alchemy/storage_adapter.rb index c2a4fb96d1..de142577ba 100644 --- a/app/models/alchemy/storage_adapter.rb +++ b/app/models/alchemy/storage_adapter.rb @@ -25,6 +25,7 @@ class UnknownAdapterError < StandardError; end :image_file_size, :image_file_width, :picture_url_class, + :preload_picture_associations, :preloaded_pictures, :preprocessor_class, :ransackable_associations, diff --git a/app/models/alchemy/storage_adapter/active_storage.rb b/app/models/alchemy/storage_adapter/active_storage.rb index 1866137af6..084fcf2d9d 100644 --- a/app/models/alchemy/storage_adapter/active_storage.rb +++ b/app/models/alchemy/storage_adapter/active_storage.rb @@ -188,6 +188,15 @@ def preloaded_pictures(pictures) pictures.with_attached_image_file end + # Preload picture associations on already-loaded records + # @param [Array] pictures + def preload_picture_associations(pictures) + ActiveRecord::Associations::Preloader.new( + records: pictures, + associations: {image_file_attachment: :blob} + ).call + end + # @param [Alchemy::Attachment] # @return [TrueClass, FalseClass] def set_attachment_name?(attachment) diff --git a/app/models/alchemy/storage_adapter/dragonfly.rb b/app/models/alchemy/storage_adapter/dragonfly.rb index ace494f43b..c84422db9c 100644 --- a/app/models/alchemy/storage_adapter/dragonfly.rb +++ b/app/models/alchemy/storage_adapter/dragonfly.rb @@ -213,6 +213,15 @@ def preloaded_pictures(pictures) pictures.includes(:thumbs) end + # Preload picture associations on already-loaded records + # @param [Array] pictures + def preload_picture_associations(pictures) + ActiveRecord::Associations::Preloader.new( + records: pictures, + associations: :thumbs + ).call + end + # @param [Alchemy::Attachment] # @return [TrueClass, FalseClass] def set_attachment_name?(attachment) diff --git a/spec/models/alchemy/picture_spec.rb b/spec/models/alchemy/picture_spec.rb index 3c38094313..3a4d7a6593 100644 --- a/spec/models/alchemy/picture_spec.rb +++ b/spec/models/alchemy/picture_spec.rb @@ -394,14 +394,28 @@ module Alchemy expect(picture2.instance_variable_get(:@preloaded_description)).to eq(description2) end - it "uses only one query for all descriptions" do - query_count = 0 - counter = ->(*, _) { query_count += 1 } - ActiveSupport::Notifications.subscribed(counter, "sql.active_record") do + it "preloads descriptions and thumbs", if: Alchemy.storage_adapter.dragonfly? do + # 1 query for descriptions + 1 query for thumbs + expect { described_class.alchemy_element_preloads([picture1, picture2], language: language) - end + }.to make_database_queries(count: 2) + + expect(picture1.association(:thumbs)).to be_loaded + expect(picture2.association(:thumbs)).to be_loaded + end + + it "preloads descriptions and image file", if: Alchemy.storage_adapter.active_storage? do + # Reload pictures to clear already-loaded associations from factory + reloaded1 = Picture.find(picture1.id) + reloaded2 = Picture.find(picture2.id) + + # 1 query for descriptions + 2 queries for image_file_attachment + blob + expect { + described_class.alchemy_element_preloads([reloaded1, reloaded2], language: language) + }.to make_database_queries(count: 3) - expect(query_count).to eq(1) + expect(reloaded1.association(:image_file_attachment)).to be_loaded + expect(reloaded2.association(:image_file_attachment)).to be_loaded end end diff --git a/spec/services/alchemy/element_preloader_spec.rb b/spec/services/alchemy/element_preloader_spec.rb index 74d502664a..8b495325f6 100644 --- a/spec/services/alchemy/element_preloader_spec.rb +++ b/spec/services/alchemy/element_preloader_spec.rb @@ -175,7 +175,7 @@ # Queries are bounded regardless of element count (8 elements created) # Root elements, all elements, ingredients, related objects, tags - # No language passed, so no description preloading + # No language passed, so no description/thumb preloading expect { result = described_class.new(elements: elements).call @@ -269,9 +269,11 @@ elements = page_version.elements.not_nested.order(:position) # Expected queries: - # Root elements (2x from includes), all elements, ingredients (2x), - # related objects (2x), tags (2x), picture descriptions + # Root elements, all elements, ingredients, related objects, tags, + # picture descriptions, picture storage associations (thumbs for Dragonfly, attachment+blob for ActiveStorage) # No additional queries when accessing descriptions (preloaded) + expected_queries = Alchemy.storage_adapter.dragonfly? ? 11 : 12 + expect { preloaded = described_class.new(elements: elements, language: language).call @@ -283,7 +285,7 @@ end end end - }.to make_database_queries(count: 10) + }.to make_database_queries(count: expected_queries) end end