Skip to content

Commit 476cc5d

Browse files
committed
feat(performance): 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.
1 parent 565a3cd commit 476cc5d

File tree

6 files changed

+51
-11
lines changed

6 files changed

+51
-11
lines changed

app/models/alchemy/picture.rb

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,21 +134,26 @@ def file_formats(scope = all)
134134
Alchemy.storage_adapter.file_formats(name, scope:)
135135
end
136136

137-
# Preload descriptions for element editor display
137+
# Preload associations for element editor display
138138
#
139139
# @param pictures [Array<Picture>] Collection of pictures to preload for
140140
# @param language [Alchemy::Language] Current language for descriptions
141141
def alchemy_element_preloads(pictures, language:)
142142
return if pictures.blank? || language.nil?
143143

144144
picture_ids = pictures.map(&:id).uniq
145+
146+
# Preload descriptions
145147
descriptions = Alchemy::PictureDescription
146148
.where(picture_id: picture_ids, language: language)
147149
.index_by(&:picture_id)
148150

149151
pictures.each do |picture|
150152
picture.instance_variable_set(:@preloaded_description, descriptions[picture.id])
151153
end
154+
155+
# Preload storage-specific associations to avoid N+1 when rendering thumbnails
156+
Alchemy.storage_adapter.preload_picture_associations(pictures)
152157
end
153158
end
154159

app/models/alchemy/storage_adapter.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ class UnknownAdapterError < StandardError; end
2525
:image_file_size,
2626
:image_file_width,
2727
:picture_url_class,
28+
:preload_picture_associations,
2829
:preloaded_pictures,
2930
:preprocessor_class,
3031
:ransackable_associations,

app/models/alchemy/storage_adapter/active_storage.rb

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,15 @@ def preloaded_pictures(pictures)
188188
pictures.with_attached_image_file
189189
end
190190

191+
# Preload picture associations on already-loaded records
192+
# @param [Array<Alchemy::Picture>] pictures
193+
def preload_picture_associations(pictures)
194+
ActiveRecord::Associations::Preloader.new(
195+
records: pictures,
196+
associations: {image_file_attachment: :blob}
197+
).call
198+
end
199+
191200
# @param [Alchemy::Attachment]
192201
# @return [TrueClass, FalseClass]
193202
def set_attachment_name?(attachment)

app/models/alchemy/storage_adapter/dragonfly.rb

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,15 @@ def preloaded_pictures(pictures)
213213
pictures.includes(:thumbs)
214214
end
215215

216+
# Preload picture associations on already-loaded records
217+
# @param [Array<Alchemy::Picture>] pictures
218+
def preload_picture_associations(pictures)
219+
ActiveRecord::Associations::Preloader.new(
220+
records: pictures,
221+
associations: :thumbs
222+
).call
223+
end
224+
216225
# @param [Alchemy::Attachment]
217226
# @return [TrueClass, FalseClass]
218227
def set_attachment_name?(attachment)

spec/models/alchemy/picture_spec.rb

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -394,14 +394,28 @@ module Alchemy
394394
expect(picture2.instance_variable_get(:@preloaded_description)).to eq(description2)
395395
end
396396

397-
it "uses only one query for all descriptions" do
398-
query_count = 0
399-
counter = ->(*, _) { query_count += 1 }
400-
ActiveSupport::Notifications.subscribed(counter, "sql.active_record") do
397+
it "preloads descriptions and thumbs", if: Alchemy.storage_adapter.dragonfly? do
398+
# 1 query for descriptions + 1 query for thumbs
399+
expect {
401400
described_class.alchemy_element_preloads([picture1, picture2], language: language)
402-
end
401+
}.to make_database_queries(count: 2)
402+
403+
expect(picture1.association(:thumbs)).to be_loaded
404+
expect(picture2.association(:thumbs)).to be_loaded
405+
end
406+
407+
it "preloads descriptions and image file", if: Alchemy.storage_adapter.active_storage? do
408+
# Reload pictures to clear already-loaded associations from factory
409+
reloaded1 = Picture.find(picture1.id)
410+
reloaded2 = Picture.find(picture2.id)
411+
412+
# 1 query for descriptions + 2 queries for image_file_attachment + blob
413+
expect {
414+
described_class.alchemy_element_preloads([reloaded1, reloaded2], language: language)
415+
}.to make_database_queries(count: 3)
403416

404-
expect(query_count).to eq(1)
417+
expect(reloaded1.association(:image_file_attachment)).to be_loaded
418+
expect(reloaded2.association(:image_file_attachment)).to be_loaded
405419
end
406420
end
407421

spec/services/alchemy/element_preloader_spec.rb

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,7 @@
175175

176176
# Queries are bounded regardless of element count (8 elements created)
177177
# Root elements, all elements, ingredients, related objects, tags
178-
# No language passed, so no description preloading
178+
# No language passed, so no description/thumb preloading
179179
expect {
180180
result = described_class.new(elements: elements).call
181181

@@ -269,9 +269,11 @@
269269
elements = page_version.elements.not_nested.order(:position)
270270

271271
# Expected queries:
272-
# Root elements (2x from includes), all elements, ingredients (2x),
273-
# related objects (2x), tags (2x), picture descriptions
272+
# Root elements, all elements, ingredients, related objects, tags,
273+
# picture descriptions, picture storage associations (thumbs for Dragonfly, attachment+blob for ActiveStorage)
274274
# No additional queries when accessing descriptions (preloaded)
275+
expected_queries = Alchemy.storage_adapter.dragonfly? ? 11 : 12
276+
275277
expect {
276278
preloaded = described_class.new(elements: elements, language: language).call
277279

@@ -283,7 +285,7 @@
283285
end
284286
end
285287
end
286-
}.to make_database_queries(count: 10)
288+
}.to make_database_queries(count: expected_queries)
287289
end
288290
end
289291

0 commit comments

Comments
 (0)