feat(member): resolve custom-field filters and sorting in PostgreSQL

Push the boolean and date custom-field predicates and custom-field
sorting down to JSONB expressions so the overview stops classifying
custom-field values in memory. A GIN index on custom_field_values.value
keeps the boolean membership predicates index-served.
This commit is contained in:
Simon 2026-07-03 11:19:59 +02:00
parent e64f55c36a
commit b09cdf7f3a
9 changed files with 883 additions and 5 deletions

View file

@ -18,6 +18,7 @@ defmodule MvWeb.MemberLive.Index.OverviewQuery do
alias MvWeb.MemberLive.Index.DateFilter
require Ash.Query
require Ash.Sort
@type opts :: %{optional(atom()) => term()}
@ -40,13 +41,18 @@ defmodule MvWeb.MemberLive.Index.OverviewQuery do
|> apply_search(opts[:search])
|> apply_group_filters(opts[:group_filters], opts[:groups])
|> apply_fee_type_filters(opts[:fee_type_filters], opts[:fee_types])
|> apply_boolean_custom_field_filters(
opts[:boolean_custom_field_filters],
opts[:boolean_custom_fields]
)
|> apply_date_filters(opts[:date_filters])
|> apply_custom_date_filters(opts[:date_filters], opts[:date_custom_fields])
|> apply_cycle_status_filter(
opts[:cycle_status_filter],
opts[:show_current_cycle],
today(opts)
)
|> apply_sort(opts[:sort_field], opts[:sort_order])
|> apply_sort(opts[:sort_field], opts[:sort_order], opts[:custom_fields] || [])
end
defp today(opts), do: opts[:today] || Date.utc_today()
@ -135,6 +141,48 @@ defmodule MvWeb.MemberLive.Index.OverviewQuery do
end)
end
# ---------------------------------------------------------------------------
# Boolean custom-field filters (AND across selected fields)
#
# Mirrors the previous in-memory behaviour: a member matches a `{field => bool}`
# filter only if it has a stored value row for that field whose boolean value
# equals `bool`. A member with no value row is excluded. Uses a JSONB
# containment predicate (`value @> '{"value": <bool>}'`), which the GIN index on
# `custom_field_values.value` can serve.
# ---------------------------------------------------------------------------
defp apply_boolean_custom_field_filters(query, filters, _boolean_fields)
when filters in [nil, %{}],
do: query
defp apply_boolean_custom_field_filters(query, filters, boolean_fields) do
valid_ids = valid_id_set(boolean_fields)
filters
|> Enum.filter(fn {id_str, _} -> MapSet.member?(valid_ids, id_str) end)
|> Enum.reduce(query, fn {id_str, bool}, q ->
case Ecto.UUID.cast(id_str) do
{:ok, uuid} -> apply_one_boolean_filter(q, uuid, bool)
_ -> q
end
end)
end
defp apply_one_boolean_filter(query, uuid, bool) when is_boolean(bool) do
Ash.Query.filter(
query,
expr(
exists(
custom_field_values,
custom_field_id == ^uuid and
fragment("? @> jsonb_build_object('value', ?::boolean)", value, ^bool)
)
)
)
end
defp apply_one_boolean_filter(query, _uuid, _bool), do: query
# ---------------------------------------------------------------------------
# Cycle-status filter (paid/unpaid, current or last-completed cycle)
#
@ -193,20 +241,99 @@ defmodule MvWeb.MemberLive.Index.OverviewQuery do
defp apply_date_filters(query, filters) when is_map(filters),
do: DateFilter.apply_ash_filter(query, filters)
defp apply_custom_date_filters(query, filters, custom_fields)
when is_map(filters) and is_list(custom_fields),
do: DateFilter.apply_custom_date_ash_filter(query, filters, custom_fields)
defp apply_custom_date_filters(query, _filters, _custom_fields), do: query
# ---------------------------------------------------------------------------
# Sort (always append the unique id tie-breaker for keyset stability)
# ---------------------------------------------------------------------------
defp apply_sort(query, nil, _order), do: Ash.Query.sort(query, id: :asc)
defp apply_sort(query, _field, nil), do: Ash.Query.sort(query, id: :asc)
defp apply_sort(query, nil, _order, _custom_fields), do: Ash.Query.sort(query, id: :asc)
defp apply_sort(query, _field, nil, _custom_fields), do: Ash.Query.sort(query, id: :asc)
defp apply_sort(query, field, order) do
defp apply_sort(query, field, order, custom_fields) do
case sort_key(field) do
nil -> Ash.Query.sort(query, id: :asc)
nil -> apply_custom_field_sort(query, field, order, custom_fields)
key -> Ash.Query.sort(query, [{key, order}, {:id, :asc}])
end
end
# Sorts by a custom field's JSONB value DB-side. The value is read from the
# first matching `custom_field_values` row for the field, cast per the field's
# value type so integers sort numerically and dates chronologically. Empty
# strings and members without a value row sort last in both directions
# (NULLS LAST), mirroring the previous in-memory `CustomFieldSort` behaviour.
defp apply_custom_field_sort(query, field, order, custom_fields) do
with id_str when is_binary(id_str) <- custom_field_id(field),
%{} = cf <- Enum.find(custom_fields, &(to_string(&1.id) == id_str)) do
nils_order = if order == :desc, do: :desc_nils_last, else: :asc_nils_last
Ash.Query.sort(query, [
{custom_field_sort_calc(cf.value_type, cf.id), nils_order},
{:id, :asc}
])
else
_ -> Ash.Query.sort(query, id: :asc)
end
end
defp custom_field_id(field) when is_atom(field), do: custom_field_id(Atom.to_string(field))
defp custom_field_id(field) when is_binary(field) do
case String.split(field, Mv.Constants.custom_field_prefix()) do
["", id_str] -> id_str
_ -> nil
end
end
defp custom_field_id(_), do: nil
# Maps the field's value type to the first matching custom-field value's typed
# sort key (scoped to the field), backed by the `sort_*` calculations on
# `CustomFieldValue` (a raw fragment is not allowed as an aggregate `field:`).
defp custom_field_sort_calc(:integer, id),
do:
Ash.Sort.expr_sort(
first(custom_field_values,
field: :sort_numeric,
query: [filter: expr(custom_field_id == ^id)]
),
:decimal
)
defp custom_field_sort_calc(:date, id),
do:
Ash.Sort.expr_sort(
first(custom_field_values,
field: :sort_date,
query: [filter: expr(custom_field_id == ^id)]
),
:date
)
defp custom_field_sort_calc(:boolean, id),
do:
Ash.Sort.expr_sort(
first(custom_field_values,
field: :sort_boolean,
query: [filter: expr(custom_field_id == ^id)]
),
:boolean
)
defp custom_field_sort_calc(_type, id),
do:
Ash.Sort.expr_sort(
first(custom_field_values,
field: :sort_text,
query: [filter: expr(custom_field_id == ^id)]
),
:string
)
# Resolves a sort field (atom or string) to a DB sort key, or nil if it is a
# computed field handled elsewhere / not sortable.
defp sort_key(field) when field in [:membership_fee_type, "membership_fee_type"],