berrypod/lib/berrypod/products.ex
jamey 297f3de60f
All checks were successful
deploy / deploy (push) Successful in 1m26s
cache settings, categories, and media in ETS to cut per-request DB queries
Every shop page load was triggering ~18 DB queries for data that rarely
changes (theme settings, nav items, categories, shipping countries, logo,
header image). On a shared-cpu-1x Fly machine with SQLite this was the
primary performance bottleneck.

- Add SettingsCache GenServer+ETS for all non-encrypted settings
- Cache list_categories() with single-query N+1 fix (correlated subquery)
- Cache list_available_countries_with_names() in shipping
- Cache Media.get_logo() and Media.get_header()
- Remove duplicate LoadTheme plug from :shop and :admin pipelines
- Invalidate caches on writes (put_setting, product sync, media upload)
- Clear caches between tests via DataCase/ConnCase setup

Per-page queries reduced from ~18 to ~2.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 16:25:29 +00:00

938 lines
27 KiB
Elixir

defmodule Berrypod.Products do
@moduledoc """
The Products context.
Manages products synced from POD providers, including provider connections,
products, images, and variants.
"""
import Ecto.Query
alias Berrypod.Repo
alias Berrypod.Products.{ProviderConnection, Product, ProductImage, ProductVariant}
# =============================================================================
# Provider Connections
# =============================================================================
@doc """
Returns the list of provider connections.
"""
def list_provider_connections do
Repo.all(ProviderConnection)
end
@doc """
Gets a single provider connection.
"""
def get_provider_connection(id) do
Repo.get(ProviderConnection, id)
end
@doc """
Gets a single provider connection, raising if not found.
"""
def get_provider_connection!(id) do
Repo.get!(ProviderConnection, id)
end
@doc """
Gets a provider connection by type.
"""
def get_provider_connection_by_type(provider_type) do
Repo.get_by(ProviderConnection, provider_type: provider_type)
end
@doc """
Returns the first provider connection with an API key, or nil.
Provider-agnostic — doesn't care which type. Ordered by creation date.
"""
def get_first_provider_connection do
ProviderConnection
|> where([c], not is_nil(c.api_key_encrypted))
|> order_by(:inserted_at)
|> limit(1)
|> Repo.one()
end
@doc """
Creates a provider connection.
"""
def create_provider_connection(attrs \\ %{}) do
%ProviderConnection{}
|> ProviderConnection.changeset(attrs)
|> Repo.insert()
end
@doc """
Updates a provider connection.
"""
def update_provider_connection(%ProviderConnection{} = conn, attrs) do
conn
|> ProviderConnection.changeset(attrs)
|> Repo.update()
end
@doc """
Deletes a provider connection.
"""
def delete_provider_connection(%ProviderConnection{} = conn) do
Repo.delete(conn)
end
@doc """
Updates the sync status of a provider connection.
"""
def update_sync_status(%ProviderConnection{} = conn, status, synced_at \\ nil) do
attrs = %{sync_status: status}
attrs = if synced_at, do: Map.put(attrs, :last_synced_at, synced_at), else: attrs
conn
|> ProviderConnection.sync_changeset(attrs)
|> Repo.update()
end
@doc """
Resets any stale "syncing" status to "idle".
Called on application startup to recover from interrupted syncs
(e.g., node shutdown while sync was running).
"""
def reset_stale_sync_status do
from(c in ProviderConnection, where: c.sync_status == "syncing")
|> Repo.update_all(set: [sync_status: "idle"])
end
@doc """
Returns the total count of all products.
"""
def count_products do
Repo.aggregate(Product, :count)
end
@doc """
Returns the count of products for a provider connection.
"""
def count_products_for_connection(nil), do: 0
def count_products_for_connection(connection_id) do
from(p in Product, where: p.provider_connection_id == ^connection_id, select: count())
|> Repo.one()
end
@doc """
Enqueues a product sync job for the given provider connection.
Returns `{:ok, job}` or `{:error, changeset}`.
"""
def enqueue_sync(%ProviderConnection{} = conn) do
Berrypod.Sync.ProductSyncWorker.enqueue(conn.id)
end
# =============================================================================
# Storefront queries
# =============================================================================
# Listing pages only need images (price/stock are denormalized on product)
@listing_preloads [images: :image]
# Detail page also needs variants for the variant selector
@detail_preloads [images: :image, variants: []]
@doc """
Gets a single visible, active product by slug with full preloads (for detail page).
"""
def get_visible_product(slug) do
Product
|> where([p], p.slug == ^slug and p.visible == true and p.status == "active")
|> preload(^@detail_preloads)
|> Repo.one()
end
@doc """
Lists visible, active products with listing preloads (no variants).
## Options
* `:sort` - sort order: "price_asc", "price_desc", "newest", "name_asc", "name_desc"
* `:category` - filter by category name
* `:on_sale` - if true, only products on sale
* `:in_stock` - if true, only products in stock
* `:limit` - max number of results
* `:exclude` - product ID to exclude
"""
def list_visible_products(opts \\ []) do
Product
|> where([p], p.visible == true and p.status == "active")
|> apply_visible_filters(opts)
|> apply_sort(opts[:sort])
|> maybe_limit(opts[:limit])
|> maybe_exclude(opts[:exclude])
|> preload(^@listing_preloads)
|> Repo.all()
end
@doc """
Like `list_visible_products/1` but returns a `%Pagination{}` struct.
Accepts the same filter/sort options plus `:page` and `:per_page`.
"""
def list_visible_products_paginated(opts \\ []) do
Product
|> where([p], p.visible == true and p.status == "active")
|> apply_visible_filters(opts)
|> apply_sort(opts[:sort])
|> maybe_exclude(opts[:exclude])
|> preload(^@listing_preloads)
|> Berrypod.Pagination.paginate(page: opts[:page], per_page: opts[:per_page] || 24)
end
@doc """
Lists distinct categories from visible, active products.
Returns a list of `%{name, slug, image_url}` where `image_url` is the
first product image for a representative product in that category.
Results are cached in ETS and invalidated when products change.
"""
def list_categories do
alias Berrypod.Settings.SettingsCache
case SettingsCache.get_cached(:categories) do
{:ok, categories} ->
categories
:miss ->
categories = do_list_categories()
SettingsCache.put_cached(:categories, categories)
categories
end
end
# Single query: categories with their first product image (by position).
# Uses a correlated subquery to pick the lowest-position image per category,
# replacing the previous N+1 pattern.
defp do_list_categories do
# Categories that have at least one product image
with_images =
from(p in Product,
join: pi in ProductImage,
on: pi.product_id == p.id,
where: p.visible == true and p.status == "active" and not is_nil(p.category),
where:
pi.id ==
fragment(
"""
(SELECT pi2.id FROM product_images pi2
JOIN products p2 ON pi2.product_id = p2.id
WHERE p2.category = ? AND p2.visible = 1 AND p2.status = 'active'
ORDER BY pi2.position ASC LIMIT 1)
""",
p.category
),
group_by: p.category,
order_by: p.category,
select: {p.category, pi.image_id, pi.src}
)
|> Repo.all()
categories_with_images = MapSet.new(with_images, &elem(&1, 0))
# Categories without any images (still need to appear in the list)
without_images =
from(p in Product,
where: p.visible == true and p.status == "active" and not is_nil(p.category),
where: p.category not in ^MapSet.to_list(categories_with_images),
select: p.category,
distinct: true,
order_by: p.category
)
|> Repo.all()
|> Enum.map(&{&1, nil, nil})
(with_images ++ without_images)
|> Enum.sort_by(&elem(&1, 0))
|> Enum.map(fn {name, image_id, src} ->
image_url =
cond do
not is_nil(image_id) -> "/image_cache/#{image_id}-400.webp"
is_binary(src) -> src
true -> nil
end
%{name: name, slug: Slug.slugify(name), image_url: image_url}
end)
end
@doc """
Recomputes denormalized fields from a product's variants.
Called after variant sync to keep cached fields up to date.
"""
def recompute_cached_fields(%Product{} = product) do
variants = Repo.all(from v in ProductVariant, where: v.product_id == ^product.id)
available = Enum.filter(variants, &(&1.is_enabled and &1.is_available))
cheapest = Enum.min_by(available, & &1.price, fn -> nil end)
attrs = %{
cheapest_price: if(cheapest, do: cheapest.price, else: 0),
compare_at_price: if(cheapest, do: cheapest.compare_at_price),
in_stock: available != [],
on_sale: Enum.any?(variants, &ProductVariant.on_sale?/1)
}
product
|> Product.recompute_changeset(attrs)
|> Repo.update()
end
defp apply_visible_filters(query, opts) do
query
|> maybe_filter_category(opts[:category])
|> maybe_filter_on_sale(opts[:on_sale])
|> maybe_filter_in_stock(opts[:in_stock])
end
defp maybe_filter_category(query, nil), do: query
defp maybe_filter_category(query, name), do: where(query, [p], p.category == ^name)
defp maybe_filter_on_sale(query, true), do: where(query, [p], p.on_sale == true)
defp maybe_filter_on_sale(query, _), do: query
defp maybe_filter_in_stock(query, true), do: where(query, [p], p.in_stock == true)
defp maybe_filter_in_stock(query, false), do: where(query, [p], p.in_stock == false)
defp maybe_filter_in_stock(query, _), do: query
defp apply_sort(query, "price_asc"), do: order_by(query, [p], asc: p.cheapest_price)
defp apply_sort(query, "price_desc"), do: order_by(query, [p], desc: p.cheapest_price)
defp apply_sort(query, "newest"), do: order_by(query, [p], desc: p.inserted_at)
defp apply_sort(query, "name_asc"), do: order_by(query, [p], asc: p.title)
defp apply_sort(query, "name_desc"), do: order_by(query, [p], desc: p.title)
defp apply_sort(query, _), do: order_by(query, [p], desc: p.inserted_at)
defp maybe_limit(query, nil), do: query
defp maybe_limit(query, n) when is_integer(n), do: limit(query, ^n)
defp maybe_exclude(query, nil), do: query
defp maybe_exclude(query, id), do: where(query, [p], p.id != ^id)
# =============================================================================
# Products
# =============================================================================
@doc """
Returns the list of products.
## Options
* `:visible` - filter by visibility (boolean)
* `:status` - filter by status (string)
* `:category` - filter by category (string)
* `:provider_connection_id` - filter by provider connection
* `:preload` - list of associations to preload
"""
def list_products(opts \\ []) do
Product
|> apply_product_filters(opts)
|> order_by([p], desc: p.inserted_at)
|> maybe_preload(opts[:preload])
|> Repo.all()
end
@doc """
Returns products for the admin list page with sorting, stock filtering,
and full preloads for display.
## Options
* `:visible` - filter by visibility (boolean)
* `:status` - filter by status (string)
* `:category` - filter by category (string)
* `:provider_connection_id` - filter by provider connection
* `:in_stock` - filter by stock status (boolean)
* `:sort` - sort order (string)
"""
def list_products_admin(opts \\ []) do
Product
|> apply_product_filters(opts)
|> maybe_filter_in_stock(opts[:in_stock])
|> apply_sort(opts[:sort])
|> preload([:provider_connection, images: :image, variants: []])
|> Repo.all()
end
@doc """
Like `list_products_admin/1` but returns a `%Pagination{}` struct.
Accepts the same filter options plus `:page` and `:per_page`.
"""
def list_products_admin_paginated(opts \\ []) do
Product
|> apply_product_filters(opts)
|> maybe_filter_in_stock(opts[:in_stock])
|> apply_sort(opts[:sort])
|> preload([:provider_connection, images: :image, variants: []])
|> Berrypod.Pagination.paginate(page: opts[:page], per_page: opts[:per_page] || 25)
end
@doc """
Returns distinct category names from all products (including hidden/draft).
"""
def list_all_categories do
from(p in Product,
where: not is_nil(p.category),
select: p.category,
distinct: true,
order_by: p.category
)
|> Repo.all()
end
@doc """
Gets a single product by ID.
"""
def get_product(id, opts \\ []) do
Product
|> maybe_preload(opts[:preload])
|> Repo.get(id)
end
@doc """
Gets a single product by slug.
"""
def get_product_by_slug(slug, opts \\ []) do
Product
|> maybe_preload(opts[:preload])
|> Repo.get_by(slug: slug)
end
@doc """
Gets a product by provider connection and provider product ID.
"""
def get_product_by_provider(provider_connection_id, provider_product_id) do
Repo.get_by(Product,
provider_connection_id: provider_connection_id,
provider_product_id: provider_product_id
)
end
@doc """
Creates a product.
"""
def create_product(attrs \\ %{}) do
%Product{}
|> Product.changeset(attrs)
|> Repo.insert()
end
@doc """
Updates a product.
"""
def update_product(%Product{} = product, attrs) do
product
|> Product.changeset(attrs)
|> Repo.update()
end
@doc """
Updates storefront-only fields (visibility and category).
"""
def update_storefront(%Product{} = product, attrs) do
result =
product
|> Product.storefront_changeset(attrs)
|> Repo.update()
case result do
{:ok, _} -> Berrypod.Settings.SettingsCache.invalidate_cached(:categories)
_ -> :ok
end
result
end
@doc """
Toggles a product's visibility.
"""
def toggle_visibility(%Product{} = product) do
update_storefront(product, %{visible: !product.visible})
end
@doc """
Deletes a product.
"""
def delete_product(%Product{} = product) do
# Create a redirect before deletion so the old URL doesn't 404
target =
if product.category do
"/collections/#{Slug.slugify(product.category)}"
else
"/"
end
Berrypod.Redirects.create_auto(%{
from_path: "/products/#{product.slug}",
to_path: target,
source: "auto_product_deleted"
})
result = Repo.delete(product)
case result do
{:ok, _} -> Berrypod.Settings.SettingsCache.invalidate_cached(:categories)
_ -> :ok
end
result
end
@doc """
Upserts a product from provider data.
Creates a new product if one doesn't exist for the given provider connection
and provider product ID. Updates the existing product if checksum differs.
Returns `{:ok, product, :created | :updated | :unchanged}`.
"""
def upsert_product(%ProviderConnection{id: conn_id}, attrs) do
provider_product_id = attrs[:provider_product_id] || attrs["provider_product_id"]
new_checksum = Product.compute_checksum(attrs[:provider_data] || attrs["provider_data"])
title = attrs[:title] || attrs["title"]
attrs =
attrs
|> Map.put(:checksum, new_checksum)
|> Map.put(:provider_connection_id, conn_id)
# First check by provider_product_id
case get_product_by_provider(conn_id, provider_product_id) do
nil ->
# Not found by provider ID - check by slug (same title = same product)
slug = Slug.slugify(title)
find_by_slug_or_insert(conn_id, slug, attrs, new_checksum)
%Product{checksum: ^new_checksum} = product ->
{:ok, product, :unchanged}
product ->
old_slug = product.slug
case update_product(product, attrs) do
{:ok, updated} ->
if old_slug != updated.slug do
Berrypod.Redirects.create_auto(%{
from_path: "/products/#{old_slug}",
to_path: "/products/#{updated.slug}",
source: "auto_slug_change"
})
end
{:ok, updated, :updated}
error ->
error
end
end
end
# If product exists with same slug, update it (including new provider_product_id)
# Otherwise insert new product
defp find_by_slug_or_insert(conn_id, slug, attrs, new_checksum) do
case get_product_by_slug(slug) do
%Product{provider_connection_id: ^conn_id, checksum: ^new_checksum} = product ->
# Same product, same checksum - just update the provider_product_id if changed
if product.provider_product_id != attrs[:provider_product_id] do
case update_product(product, %{provider_product_id: attrs[:provider_product_id]}) do
{:ok, product} -> {:ok, product, :updated}
error -> error
end
else
{:ok, product, :unchanged}
end
%Product{provider_connection_id: ^conn_id} = product ->
# Same product, different checksum - full update including new provider_product_id
case update_product(product, attrs) do
{:ok, product} -> {:ok, product, :updated}
error -> error
end
nil ->
# Not found at all - insert new
do_insert_product(attrs)
_different_connection ->
# Slug taken by a different provider connection - make it unique
unique_slug = make_unique_slug(slug)
do_insert_product(Map.put(attrs, :slug, unique_slug))
end
end
defp make_unique_slug(base_slug, suffix \\ 2) do
candidate = "#{base_slug}-#{suffix}"
case Repo.get_by(Product, slug: candidate) do
nil -> candidate
_ -> make_unique_slug(base_slug, suffix + 1)
end
end
# Insert with conflict handling for race conditions
defp do_insert_product(attrs) do
case create_product(attrs) do
{:ok, product} ->
{:ok, product, :created}
{:error, %Ecto.Changeset{errors: errors} = changeset} ->
# Check if it's a unique constraint violation (race condition)
if has_unique_constraint_error?(errors) do
handle_insert_conflict(attrs, changeset)
else
{:error, changeset}
end
end
end
defp handle_insert_conflict(attrs, changeset) do
conn_id = attrs[:provider_connection_id]
provider_product_id = attrs[:provider_product_id]
new_checksum = attrs[:checksum]
case get_product_by_provider(conn_id, provider_product_id) do
nil ->
{:error, changeset}
%Product{checksum: ^new_checksum} = product ->
{:ok, product, :unchanged}
product ->
case update_product(product, attrs) do
{:ok, product} -> {:ok, product, :updated}
error -> error
end
end
end
defp has_unique_constraint_error?(errors) do
Enum.any?(errors, fn
{_field, {_msg, [constraint: :unique, constraint_name: _]}} -> true
_ -> false
end)
end
# =============================================================================
# Product Images
# =============================================================================
@doc """
Creates a product image.
"""
def create_product_image(attrs \\ %{}) do
%ProductImage{}
|> ProductImage.changeset(attrs)
|> Repo.insert()
end
@doc """
Gets a single product image by ID.
"""
def get_product_image(id) do
Repo.get(ProductImage, id)
end
@doc """
Lists all images for a product, ordered by position.
"""
def list_product_images(product_id) do
from(i in ProductImage, where: i.product_id == ^product_id, order_by: i.position)
|> Repo.all()
end
@doc """
Updates a product image with the given attributes.
"""
def update_product_image(%ProductImage{} = product_image, attrs) do
product_image
|> ProductImage.changeset(attrs)
|> Repo.update()
end
@doc """
Links a product image to a Media.Image by setting its image_id.
"""
def link_product_image(%ProductImage{} = product_image, image_id) do
product_image
|> ProductImage.changeset(%{image_id: image_id})
|> Repo.update()
end
@doc """
Lists product images that need downloading (have src but no image_id).
## Options
* `:limit` - maximum number of images to return (default: 100)
"""
def list_pending_downloads(opts \\ []) do
limit = Keyword.get(opts, :limit, 100)
from(i in ProductImage,
where: not is_nil(i.src) and is_nil(i.image_id),
order_by: [asc: i.inserted_at],
limit: ^limit
)
|> Repo.all()
end
@doc """
Deletes all images for a product, including their backing Media.Image records.
"""
def delete_product_images(%Product{id: product_id}) do
image_ids =
from(pi in ProductImage,
where: pi.product_id == ^product_id and not is_nil(pi.image_id),
select: pi.image_id
)
|> Repo.all()
result =
from(pi in ProductImage, where: pi.product_id == ^product_id)
|> Repo.delete_all()
cleanup_orphaned_images(image_ids)
result
end
@doc """
Syncs product images from a list of image data.
Preserves existing image_id references when the URL hasn't changed.
Returns a list of {:ok, image} tuples for images that need downloading.
"""
def sync_product_images(%Product{id: product_id}, images) when is_list(images) do
# Build map of existing images by position
existing_by_position =
from(i in ProductImage, where: i.product_id == ^product_id)
|> Repo.all()
|> Map.new(&{&1.position, &1})
incoming_positions =
images
|> Enum.with_index()
|> Enum.map(fn {image_data, index} -> image_data[:position] || index end)
|> MapSet.new()
# Delete orphaned positions (images no longer in the list)
orphaned =
existing_by_position
|> Enum.reject(fn {position, _img} -> MapSet.member?(incoming_positions, position) end)
orphaned_ids = Enum.map(orphaned, fn {_position, img} -> img.id end)
orphaned_image_ids =
orphaned
|> Enum.map(fn {_position, img} -> img.image_id end)
|> Enum.reject(&is_nil/1)
if orphaned_ids != [] do
from(i in ProductImage, where: i.id in ^orphaned_ids) |> Repo.delete_all()
end
# Upsert incoming images, collecting image_ids displaced by URL changes
{results, replaced_image_ids} =
images
|> Enum.with_index()
|> Enum.map_reduce([], fn {image_data, index}, acc ->
position = image_data[:position] || index
src = image_data[:src]
existing = Map.get(existing_by_position, position)
cond do
# Same URL at position - update color if needed, preserve image_id
existing && existing.src == src ->
result =
if existing.color != image_data[:color] do
existing
|> ProductImage.changeset(%{color: image_data[:color]})
|> Repo.update()
else
{:ok, existing}
end
{result, acc}
# Different URL at position - update src, clear image_id (triggers re-download)
existing ->
acc = if existing.image_id, do: [existing.image_id | acc], else: acc
result =
existing
|> ProductImage.changeset(%{
src: src,
alt: image_data[:alt],
color: image_data[:color],
image_id: nil
})
|> Repo.update()
{result, acc}
# New position - create new
true ->
attrs =
image_data
|> Map.put(:product_id, product_id)
|> Map.put(:position, position)
{create_product_image(attrs), acc}
end
end)
cleanup_orphaned_images(orphaned_image_ids ++ replaced_image_ids)
results
end
# Deletes Media.Image records that are no longer referenced by any product_image.
defp cleanup_orphaned_images([]), do: :ok
defp cleanup_orphaned_images(image_ids) do
alias Berrypod.Media.Image, as: ImageSchema
from(i in ImageSchema, where: i.id in ^image_ids)
|> Repo.delete_all()
end
# =============================================================================
# Product Variants
# =============================================================================
@doc """
Gets multiple variants by their IDs with associated products and images.
Returns a map of variant_id => variant struct for efficient lookup.
Used by Cart.hydrate/1 to fetch variant data for display.
"""
def get_variants_with_products(variant_ids) when is_list(variant_ids) do
from(v in ProductVariant,
where: v.id in ^variant_ids,
preload: [product: [images: :image]]
)
|> Repo.all()
|> Map.new(&{&1.id, &1})
end
@doc """
Creates a product variant.
"""
def create_product_variant(attrs \\ %{}) do
%ProductVariant{}
|> ProductVariant.changeset(attrs)
|> Repo.insert()
end
@doc """
Updates a product variant.
"""
def update_product_variant(%ProductVariant{} = variant, attrs) do
variant
|> ProductVariant.changeset(attrs)
|> Repo.update()
end
@doc """
Deletes all variants for a product.
"""
def delete_product_variants(%Product{id: product_id}) do
from(v in ProductVariant, where: v.product_id == ^product_id)
|> Repo.delete_all()
end
@doc """
Gets a variant by product and provider variant ID.
"""
def get_variant_by_provider(product_id, provider_variant_id) do
Repo.get_by(ProductVariant,
product_id: product_id,
provider_variant_id: provider_variant_id
)
end
@doc """
Syncs product variants from a list of variant data.
Upserts variants based on provider_variant_id.
"""
def sync_product_variants(%Product{id: product_id}, variants) when is_list(variants) do
existing_ids =
from(v in ProductVariant,
where: v.product_id == ^product_id,
select: v.provider_variant_id
)
|> Repo.all()
|> MapSet.new()
incoming_ids =
variants
|> Enum.map(&(&1[:provider_variant_id] || &1["provider_variant_id"]))
|> MapSet.new()
# Delete variants that are no longer in the incoming list
removed_ids = MapSet.difference(existing_ids, incoming_ids)
if MapSet.size(removed_ids) > 0 do
from(v in ProductVariant,
where:
v.product_id == ^product_id and v.provider_variant_id in ^MapSet.to_list(removed_ids)
)
|> Repo.delete_all()
end
# Upsert incoming variants
Enum.map(variants, fn variant_data ->
provider_variant_id =
variant_data[:provider_variant_id] || variant_data["provider_variant_id"]
attrs = Map.put(variant_data, :product_id, product_id)
case get_variant_by_provider(product_id, provider_variant_id) do
nil ->
create_product_variant(attrs)
existing ->
update_product_variant(existing, attrs)
end
end)
end
# =============================================================================
# Private Helpers
# =============================================================================
defp apply_product_filters(query, opts) do
query
|> filter_by_visible(opts[:visible])
|> filter_by_status(opts[:status])
|> filter_by_category(opts[:category])
|> filter_by_provider_connection(opts[:provider_connection_id])
end
defp filter_by_visible(query, nil), do: query
defp filter_by_visible(query, visible), do: where(query, [p], p.visible == ^visible)
defp filter_by_status(query, nil), do: query
defp filter_by_status(query, status), do: where(query, [p], p.status == ^status)
defp filter_by_category(query, nil), do: query
defp filter_by_category(query, category), do: where(query, [p], p.category == ^category)
defp filter_by_provider_connection(query, nil), do: query
defp filter_by_provider_connection(query, conn_id),
do: where(query, [p], p.provider_connection_id == ^conn_id)
defp maybe_preload(query, nil), do: query
defp maybe_preload(query, preloads), do: preload(query, ^preloads)
end