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.
388 lines
13 KiB
Elixir
388 lines
13 KiB
Elixir
defmodule MvWeb.MemberLive.Index.OverviewQuery do
|
|
@moduledoc """
|
|
Builds the Ash query for the member overview from the LiveView's filter/sort
|
|
state, so every filter and sort resolves in PostgreSQL via the `:overview`
|
|
keyset-paginated read action.
|
|
|
|
All previously in-memory passes (cycle status, boolean/date custom fields,
|
|
group sort) are expressed here as DB filters/sorts. A unique `id` tie-breaker
|
|
is always appended to the sort so keyset pages never skip or duplicate rows.
|
|
|
|
`build/1` returns an unread `Ash.Query`; the caller supplies `page:`/`actor:`
|
|
options to `Ash.read/2`.
|
|
"""
|
|
|
|
import Ash.Expr
|
|
|
|
alias Mv.Membership.Member
|
|
alias MvWeb.MemberLive.Index.DateFilter
|
|
|
|
require Ash.Query
|
|
require Ash.Sort
|
|
|
|
@type opts :: %{optional(atom()) => term()}
|
|
|
|
@doc """
|
|
Builds the `:overview` query from the given filter/sort options.
|
|
|
|
Recognised keys (all optional):
|
|
|
|
* `:search` — full-text search string
|
|
* `:group_filters` / `:groups` — `%{group_id => :in | :not_in}` and the valid groups
|
|
* `:fee_type_filters` / `:fee_types` — `%{fee_type_id => :in | :not_in}` and valid fee types
|
|
* `:date_filters` — built-in join/exit date filter map (see `DateFilter`)
|
|
* `:sort_field` / `:sort_order` — sort key and direction
|
|
* `:today` — reference date for cycle math (defaults to `Date.utc_today/0`)
|
|
"""
|
|
@spec build(opts()) :: Ash.Query.t()
|
|
def build(opts \\ %{}) do
|
|
Member
|
|
|> Ash.Query.for_read(:overview)
|
|
|> 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], opts[:custom_fields] || [])
|
|
end
|
|
|
|
defp today(opts), do: opts[:today] || Date.utc_today()
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Search
|
|
# ---------------------------------------------------------------------------
|
|
|
|
defp apply_search(query, search) when is_binary(search),
|
|
do: Member.apply_overview_search(query, search)
|
|
|
|
defp apply_search(query, _), do: query
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Group filters (AND across selected groups)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
defp apply_group_filters(query, group_filters, _groups)
|
|
when group_filters in [nil, %{}],
|
|
do: query
|
|
|
|
defp apply_group_filters(query, group_filters, groups) do
|
|
valid_ids = valid_id_set(groups)
|
|
|
|
Enum.reduce(group_filters, query, fn {group_id_str, value}, q ->
|
|
if MapSet.member?(valid_ids, group_id_str) do
|
|
apply_one_group_filter(q, group_id_str, value)
|
|
else
|
|
q
|
|
end
|
|
end)
|
|
end
|
|
|
|
defp apply_one_group_filter(query, group_id_str, :in) do
|
|
case Ecto.UUID.cast(group_id_str) do
|
|
{:ok, uuid} -> Ash.Query.filter(query, expr(exists(member_groups, group_id == ^uuid)))
|
|
_ -> query
|
|
end
|
|
end
|
|
|
|
defp apply_one_group_filter(query, group_id_str, :not_in) do
|
|
case Ecto.UUID.cast(group_id_str) do
|
|
{:ok, uuid} -> Ash.Query.filter(query, expr(not exists(member_groups, group_id == ^uuid)))
|
|
_ -> query
|
|
end
|
|
end
|
|
|
|
defp apply_one_group_filter(query, _id, _value), do: query
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fee-type filters (:in OR; :not_in AND)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
defp apply_fee_type_filters(query, fee_type_filters, _fee_types)
|
|
when fee_type_filters in [nil, %{}],
|
|
do: query
|
|
|
|
defp apply_fee_type_filters(query, fee_type_filters, fee_types) do
|
|
valid_ids = valid_id_set(fee_types)
|
|
|
|
{in_filters, not_in_filters} =
|
|
fee_type_filters
|
|
|> Enum.filter(fn {id_str, _} -> MapSet.member?(valid_ids, id_str) end)
|
|
|> Enum.split_with(fn {_, value} -> value == :in end)
|
|
|
|
in_uuids = cast_uuids(Enum.map(in_filters, fn {id_str, _} -> id_str end))
|
|
|
|
query =
|
|
if in_uuids == [] do
|
|
query
|
|
else
|
|
Ash.Query.filter(query, expr(membership_fee_type_id in ^in_uuids))
|
|
end
|
|
|
|
Enum.reduce(not_in_filters, query, fn {id_str, _}, q ->
|
|
case Ecto.UUID.cast(id_str) do
|
|
{:ok, uuid} ->
|
|
Ash.Query.filter(
|
|
q,
|
|
expr(membership_fee_type_id != ^uuid or is_nil(membership_fee_type_id))
|
|
)
|
|
|
|
_ ->
|
|
q
|
|
end
|
|
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)
|
|
#
|
|
# Backed by the DB cycle-status aggregates (denormalized `cycle_end`). A member
|
|
# matches only when its selected-cycle status equals the requested status;
|
|
# members with no matching cycle (nil aggregate) are excluded — exactly as the
|
|
# previous in-memory classifier behaved.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
defp apply_cycle_status_filter(query, status, _show_current, _today)
|
|
when status not in [:paid, :unpaid],
|
|
do: query
|
|
|
|
defp apply_cycle_status_filter(query, status, true = _show_current, today) do
|
|
# Current cycle: contains today; when several would, the one with the latest
|
|
# cycle_start wins. Keep members whose winning current cycle has `status`.
|
|
Ash.Query.filter(
|
|
query,
|
|
expr(
|
|
exists(
|
|
membership_fee_cycles,
|
|
cycle_start <= ^today and cycle_end >= ^today and status == ^status and
|
|
not exists(
|
|
member.membership_fee_cycles,
|
|
cycle_start <= ^today and cycle_end >= ^today and
|
|
cycle_start > parent(cycle_start)
|
|
)
|
|
)
|
|
)
|
|
)
|
|
end
|
|
|
|
defp apply_cycle_status_filter(query, status, _show_current, today) do
|
|
# Last completed cycle: most recent cycle that has ended (cycle_end < today).
|
|
Ash.Query.filter(
|
|
query,
|
|
expr(
|
|
exists(
|
|
membership_fee_cycles,
|
|
cycle_end < ^today and status == ^status and
|
|
not exists(
|
|
member.membership_fee_cycles,
|
|
cycle_end < ^today and cycle_start > parent(cycle_start)
|
|
)
|
|
)
|
|
)
|
|
)
|
|
end
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Built-in date filters (join/exit)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
defp apply_date_filters(query, nil), do: query
|
|
|
|
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, _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, custom_fields) do
|
|
case sort_key(field) do
|
|
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"],
|
|
do: "membership_fee_type.name"
|
|
|
|
defp sort_key(field) when field in [:groups, "groups"], do: :first_group_name
|
|
|
|
defp sort_key(field) when is_atom(field) do
|
|
if field in member_sort_fields(), do: field, else: nil
|
|
end
|
|
|
|
defp sort_key(field) when is_binary(field) do
|
|
allowed = MapSet.new(member_sort_fields(), &Atom.to_string/1)
|
|
if MapSet.member?(allowed, field), do: String.to_existing_atom(field), else: nil
|
|
end
|
|
|
|
defp sort_key(_), do: nil
|
|
|
|
defp member_sort_fields do
|
|
Mv.Constants.member_fields() -- [:notes]
|
|
end
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
defp valid_id_set(records) when is_list(records) do
|
|
records
|
|
|> Enum.map(&to_string(&1.id))
|
|
|> Enum.map(&normalize_uuid/1)
|
|
|> Enum.reject(&is_nil/1)
|
|
|> MapSet.new()
|
|
end
|
|
|
|
defp valid_id_set(_), do: MapSet.new()
|
|
|
|
defp normalize_uuid(raw) when is_binary(raw) do
|
|
case Ecto.UUID.cast(String.trim(raw)) do
|
|
{:ok, uuid} -> to_string(uuid)
|
|
_ -> nil
|
|
end
|
|
end
|
|
|
|
defp normalize_uuid(_), do: nil
|
|
|
|
defp cast_uuids(id_strs) do
|
|
id_strs
|
|
|> Enum.map(&Ecto.UUID.cast/1)
|
|
|> Enum.filter(&match?({:ok, _}, &1))
|
|
|> Enum.map(fn {:ok, uuid} -> uuid end)
|
|
end
|
|
end
|