Optimize member count queries to avoid N+1 problem

Load all member counts in a single query during mount. Counts are stored in assigns
as a map and retrieved without additional queries.
This commit is contained in:
Moritz 2025-12-22 17:40:21 +01:00
parent 46af6bbbed
commit 18766df224
Signed by: moritz
GPG key ID: 1020A035E5DD0824

View file

@ -18,15 +18,20 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do
alias Mv.MembershipFees
alias Mv.MembershipFees.MembershipFeeType
alias Mv.Membership
alias Mv.Membership.Member
alias MvWeb.Helpers.MembershipFeeHelpers
@impl true
def mount(_params, _session, socket) do
fee_types = load_membership_fee_types()
member_counts = load_member_counts(fee_types)
{:ok,
socket
|> assign(:page_title, gettext("Membership Fee Types"))
|> assign(:membership_fee_types, load_membership_fee_types())}
|> assign(:membership_fee_types, fee_types)
|> assign(:member_counts, member_counts)}
end
@impl true
@ -66,7 +71,7 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do
</:col>
<:col :let={mft} label={gettext("Members")}>
<span class="badge badge-ghost">{get_member_count(mft)}</span>
<span class="badge badge-ghost">{get_member_count(mft, @member_counts)}</span>
</:col>
<:action :let={mft}>
@ -82,18 +87,20 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do
data-confirm={gettext("Are you sure?")}
class={[
"btn btn-ghost btn-xs",
if(get_member_count(mft) > 0,
if(get_member_count(mft, @member_counts) > 0,
do: "text-error opacity-50 cursor-not-allowed",
else: "text-error"
)
]}
title={
if get_member_count(mft) > 0,
if get_member_count(mft, @member_counts) > 0,
do:
gettext("Cannot delete - %{count} member(s) assigned", count: get_member_count(mft)),
gettext("Cannot delete - %{count} member(s) assigned",
count: get_member_count(mft, @member_counts)
),
else: gettext("Delete")
}
disabled={get_member_count(mft) > 0}
disabled={get_member_count(mft, @member_counts) > 0}
>
<.icon name="hero-trash" class="size-4" />
</button>
@ -112,10 +119,12 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do
case Ash.destroy(fee_type, domain: MembershipFees) do
:ok ->
updated_types = Enum.reject(socket.assigns.membership_fee_types, &(&1.id == id))
updated_counts = Map.delete(socket.assigns.member_counts, id)
{:noreply,
socket
|> assign(:membership_fee_types, updated_types)
|> assign(:member_counts, updated_counts)
|> put_flash(:info, gettext("Membership fee type deleted"))}
{:error, error} ->
@ -131,12 +140,27 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do
|> Ash.read!(domain: MembershipFees)
end
defp get_member_count(fee_type) do
# Count members with this fee type
case Ash.count(Member |> Ash.Query.filter(membership_fee_type_id == ^fee_type.id)) do
{:ok, count} -> count
_ -> 0
end
# Loads all member counts for fee types in a single query to avoid N+1 queries
defp load_member_counts(fee_types) do
fee_type_ids = Enum.map(fee_types, & &1.id)
# Load all members with membership_fee_type_id in a single query
members =
Member
|> Ash.Query.filter(membership_fee_type_id in ^fee_type_ids)
|> Ash.Query.select([:membership_fee_type_id])
|> Ash.read!(domain: Membership)
# Group by membership_fee_type_id and count
members
|> Enum.group_by(& &1.membership_fee_type_id)
|> Enum.map(fn {fee_type_id, members_list} -> {fee_type_id, length(members_list)} end)
|> Map.new()
end
# Gets member count from preloaded assigns map
defp get_member_count(fee_type, member_counts) do
Map.get(member_counts, fee_type.id, 0)
end
defp format_error(%Ash.Error.Invalid{} = error) do