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