optimise product queries: skip image blobs, limit listing preloads, add composite index
All checks were successful
deploy / deploy (push) Successful in 1m29s
All checks were successful
deploy / deploy (push) Successful in 1m29s
The listing preload (images: :image) was loading the full images table row including the data BLOB column (~3MB per page). Now only loads :id and :source_width. Listing preloads also limited to first 2 images (primary + hover) since product cards don't use the rest. Added composite indexes on (visible, status, inserted_at) and (visible, status, category) to eliminate the TEMP B-TREE sort SQLite was doing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
297f3de60f
commit
2f3b7e7b21
@ -132,10 +132,19 @@ defmodule Berrypod.Products do
|
|||||||
# Storefront queries
|
# Storefront queries
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
# Listing pages only need images (price/stock are denormalized on product)
|
# Image preload that skips the data blob — only loads :id and :source_width
|
||||||
@listing_preloads [images: :image]
|
# from the images table. The data column holds the raw image binary (up to 5MB
|
||||||
# Detail page also needs variants for the variant selector
|
# per row) which is never used in rendering.
|
||||||
@detail_preloads [images: :image, variants: []]
|
defp image_preload_query do
|
||||||
|
from(i in Berrypod.Media.Image, select: struct(i, [:id, :source_width]))
|
||||||
|
end
|
||||||
|
|
||||||
|
# Listing pages only need the first 2 images (primary + hover) and don't
|
||||||
|
# need variants (price/stock are denormalized on the product row).
|
||||||
|
defp listing_preloads do
|
||||||
|
pi_query = from(pi in ProductImage, where: pi.position <= 1, order_by: pi.position)
|
||||||
|
[images: {pi_query, image: image_preload_query()}]
|
||||||
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Gets a single visible, active product by slug with full preloads (for detail page).
|
Gets a single visible, active product by slug with full preloads (for detail page).
|
||||||
@ -143,8 +152,11 @@ defmodule Berrypod.Products do
|
|||||||
def get_visible_product(slug) do
|
def get_visible_product(slug) do
|
||||||
Product
|
Product
|
||||||
|> where([p], p.slug == ^slug and p.visible == true and p.status == "active")
|
|> where([p], p.slug == ^slug and p.visible == true and p.status == "active")
|
||||||
|> preload(^@detail_preloads)
|
|
||||||
|> Repo.one()
|
|> Repo.one()
|
||||||
|
|> case do
|
||||||
|
nil -> nil
|
||||||
|
product -> Repo.preload(product, images: [image: image_preload_query()], variants: [])
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
@ -167,8 +179,8 @@ defmodule Berrypod.Products do
|
|||||||
|> apply_sort(opts[:sort])
|
|> apply_sort(opts[:sort])
|
||||||
|> maybe_limit(opts[:limit])
|
|> maybe_limit(opts[:limit])
|
||||||
|> maybe_exclude(opts[:exclude])
|
|> maybe_exclude(opts[:exclude])
|
||||||
|> preload(^@listing_preloads)
|
|
||||||
|> Repo.all()
|
|> Repo.all()
|
||||||
|
|> Repo.preload(listing_preloads())
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
@ -177,13 +189,15 @@ defmodule Berrypod.Products do
|
|||||||
Accepts the same filter/sort options plus `:page` and `:per_page`.
|
Accepts the same filter/sort options plus `:page` and `:per_page`.
|
||||||
"""
|
"""
|
||||||
def list_visible_products_paginated(opts \\ []) do
|
def list_visible_products_paginated(opts \\ []) do
|
||||||
Product
|
pagination =
|
||||||
|> where([p], p.visible == true and p.status == "active")
|
Product
|
||||||
|> apply_visible_filters(opts)
|
|> where([p], p.visible == true and p.status == "active")
|
||||||
|> apply_sort(opts[:sort])
|
|> apply_visible_filters(opts)
|
||||||
|> maybe_exclude(opts[:exclude])
|
|> apply_sort(opts[:sort])
|
||||||
|> preload(^@listing_preloads)
|
|> maybe_exclude(opts[:exclude])
|
||||||
|> Berrypod.Pagination.paginate(page: opts[:page], per_page: opts[:per_page] || 24)
|
|> Berrypod.Pagination.paginate(page: opts[:page], per_page: opts[:per_page] || 24)
|
||||||
|
|
||||||
|
%{pagination | items: Repo.preload(pagination.items, listing_preloads())}
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
@ -357,7 +371,7 @@ defmodule Berrypod.Products do
|
|||||||
|> apply_product_filters(opts)
|
|> apply_product_filters(opts)
|
||||||
|> maybe_filter_in_stock(opts[:in_stock])
|
|> maybe_filter_in_stock(opts[:in_stock])
|
||||||
|> apply_sort(opts[:sort])
|
|> apply_sort(opts[:sort])
|
||||||
|> preload([:provider_connection, images: :image, variants: []])
|
|> preload([:provider_connection, images: [image: ^image_preload_query()], variants: []])
|
||||||
|> Repo.all()
|
|> Repo.all()
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -371,7 +385,7 @@ defmodule Berrypod.Products do
|
|||||||
|> apply_product_filters(opts)
|
|> apply_product_filters(opts)
|
||||||
|> maybe_filter_in_stock(opts[:in_stock])
|
|> maybe_filter_in_stock(opts[:in_stock])
|
||||||
|> apply_sort(opts[:sort])
|
|> apply_sort(opts[:sort])
|
||||||
|> preload([:provider_connection, images: :image, variants: []])
|
|> preload([:provider_connection, images: [image: ^image_preload_query()], variants: []])
|
||||||
|> Berrypod.Pagination.paginate(page: opts[:page], per_page: opts[:per_page] || 25)
|
|> Berrypod.Pagination.paginate(page: opts[:page], per_page: opts[:per_page] || 25)
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -397,6 +411,27 @@ defmodule Berrypod.Products do
|
|||||||
|> Repo.get(id)
|
|> Repo.get(id)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Gets a product by ID with admin preloads (provider, images, variants).
|
||||||
|
|
||||||
|
Excludes image blob data.
|
||||||
|
"""
|
||||||
|
def get_product_with_preloads(id) do
|
||||||
|
Product
|
||||||
|
|> Repo.get(id)
|
||||||
|
|> case do
|
||||||
|
nil ->
|
||||||
|
nil
|
||||||
|
|
||||||
|
product ->
|
||||||
|
Repo.preload(product, [
|
||||||
|
:provider_connection,
|
||||||
|
images: [image: image_preload_query()],
|
||||||
|
variants: []
|
||||||
|
])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Gets a single product by slug.
|
Gets a single product by slug.
|
||||||
"""
|
"""
|
||||||
@ -817,7 +852,7 @@ defmodule Berrypod.Products do
|
|||||||
def get_variants_with_products(variant_ids) when is_list(variant_ids) do
|
def get_variants_with_products(variant_ids) when is_list(variant_ids) do
|
||||||
from(v in ProductVariant,
|
from(v in ProductVariant,
|
||||||
where: v.id in ^variant_ids,
|
where: v.id in ^variant_ids,
|
||||||
preload: [product: [images: :image]]
|
preload: [product: [images: [image: ^image_preload_query()]]]
|
||||||
)
|
)
|
||||||
|> Repo.all()
|
|> Repo.all()
|
||||||
|> Map.new(&{&1.id, &1})
|
|> Map.new(&{&1.id, &1})
|
||||||
|
|||||||
@ -11,7 +11,15 @@ defmodule Berrypod.Search do
|
|||||||
alias Berrypod.Products.Product
|
alias Berrypod.Products.Product
|
||||||
alias Berrypod.Repo
|
alias Berrypod.Repo
|
||||||
|
|
||||||
@listing_preloads [images: :image]
|
# Search results only need the first 2 images (card display) and only
|
||||||
|
# source_width from the images table — no blob data.
|
||||||
|
defp listing_preloads do
|
||||||
|
alias Berrypod.Products.ProductImage
|
||||||
|
|
||||||
|
image_query = from(i in Berrypod.Media.Image, select: struct(i, [:id, :source_width]))
|
||||||
|
pi_query = from(pi in ProductImage, where: pi.position <= 1, order_by: pi.position)
|
||||||
|
[images: {pi_query, image: image_query}]
|
||||||
|
end
|
||||||
|
|
||||||
# BM25 column weights: title(10), category(5), variant_info(3), description(1)
|
# BM25 column weights: title(10), category(5), variant_info(3), description(1)
|
||||||
@bm25_weights "10.0, 5.0, 3.0, 1.0"
|
@bm25_weights "10.0, 5.0, 3.0, 1.0"
|
||||||
@ -126,8 +134,8 @@ defmodule Berrypod.Search do
|
|||||||
Product
|
Product
|
||||||
|> where([p], p.id in ^product_ids)
|
|> where([p], p.id in ^product_ids)
|
||||||
|> where([p], p.visible == true and p.status == "active")
|
|> where([p], p.visible == true and p.status == "active")
|
||||||
|> preload(^@listing_preloads)
|
|
||||||
|> Repo.all()
|
|> Repo.all()
|
||||||
|
|> Repo.preload(listing_preloads())
|
||||||
|> Map.new(&{&1.id, &1})
|
|> Map.new(&{&1.id, &1})
|
||||||
|
|
||||||
Enum.flat_map(product_ids, fn id ->
|
Enum.flat_map(product_ids, fn id ->
|
||||||
@ -148,8 +156,8 @@ defmodule Berrypod.Search do
|
|||||||
|> where([p], like(p.title, ^pattern) or like(p.category, ^pattern))
|
|> where([p], like(p.title, ^pattern) or like(p.category, ^pattern))
|
||||||
|> order_by([p], p.title)
|
|> order_by([p], p.title)
|
||||||
|> limit(20)
|
|> limit(20)
|
||||||
|> preload(^@listing_preloads)
|
|
||||||
|> Repo.all()
|
|> Repo.all()
|
||||||
|
|> Repo.preload(listing_preloads())
|
||||||
end
|
end
|
||||||
|
|
||||||
defp sanitize_like(input) do
|
defp sanitize_like(input) do
|
||||||
|
|||||||
@ -361,7 +361,7 @@ defmodule Berrypod.Theme.PreviewData do
|
|||||||
Products.list_products(
|
Products.list_products(
|
||||||
visible: true,
|
visible: true,
|
||||||
status: "active",
|
status: "active",
|
||||||
preload: [images: :image, variants: []]
|
preload: [:images, :variants]
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@ -7,7 +7,7 @@ defmodule BerrypodWeb.Admin.ProductShow do
|
|||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def mount(%{"id" => id}, _session, socket) do
|
def mount(%{"id" => id}, _session, socket) do
|
||||||
case Products.get_product(id, preload: [:provider_connection, images: :image, variants: []]) do
|
case Products.get_product_with_preloads(id) do
|
||||||
nil ->
|
nil ->
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
|
|||||||
@ -64,7 +64,7 @@ defmodule BerrypodWeb.Admin.Products do
|
|||||||
@impl true
|
@impl true
|
||||||
def handle_event("toggle_visibility", %{"id" => id}, socket) do
|
def handle_event("toggle_visibility", %{"id" => id}, socket) do
|
||||||
product =
|
product =
|
||||||
Products.get_product(id, preload: [:provider_connection, images: :image, variants: []])
|
Products.get_product_with_preloads(id)
|
||||||
|
|
||||||
case Products.toggle_visibility(product) do
|
case Products.toggle_visibility(product) do
|
||||||
{:ok, updated} ->
|
{:ok, updated} ->
|
||||||
|
|||||||
@ -0,0 +1,14 @@
|
|||||||
|
defmodule Berrypod.Repo.Migrations.AddProductsListingIndexes do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def change do
|
||||||
|
# Composite covering index for the main listing query:
|
||||||
|
# WHERE visible = 1 AND status = 'active' ORDER BY inserted_at DESC
|
||||||
|
# Replaces the individual visible + status indexes for this pattern
|
||||||
|
# and eliminates the TEMP B-TREE sort SQLite was doing.
|
||||||
|
create index(:products, [:visible, :status, :inserted_at])
|
||||||
|
|
||||||
|
# Filtered listing by category (second most common query pattern)
|
||||||
|
create index(:products, [:visible, :status, :category])
|
||||||
|
end
|
||||||
|
end
|
||||||
Loading…
Reference in New Issue
Block a user