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:
parent
e64f55c36a
commit
b09cdf7f3a
9 changed files with 883 additions and 5 deletions
|
|
@ -48,6 +48,13 @@ defmodule Mv.Membership.CustomFieldValue do
|
|||
table "custom_field_values"
|
||||
repo Mv.Repo
|
||||
|
||||
custom_indexes do
|
||||
# GIN index on the JSONB value to serve boolean custom-field containment
|
||||
# predicates (`value @> '{"value": <bool>}'`) used by the member overview.
|
||||
# Default jsonb_ops supports the `@>` operator we rely on.
|
||||
index ["value"], name: "custom_field_values_value_gin_index", using: "gin"
|
||||
end
|
||||
|
||||
references do
|
||||
reference :member, on_delete: :delete
|
||||
reference :custom_field, on_delete: :delete
|
||||
|
|
@ -133,6 +140,16 @@ defmodule Mv.Membership.CustomFieldValue do
|
|||
|
||||
calculations do
|
||||
calculate :value_to_string, :string, expr(value[:value] <> "")
|
||||
|
||||
# Typed sort keys for DB-side ordering of the member overview by a custom
|
||||
# field. The stored JSONB value (`{"type": ..., "value": ...}`) is cast per
|
||||
# the field's value type so integers sort numerically and dates
|
||||
# chronologically. Empty strings collapse to NULL so they sort last, matching
|
||||
# the previous in-memory `CustomFieldSort` behaviour.
|
||||
calculate :sort_text, :string, expr(fragment("nullif(?->>'value','')", value))
|
||||
calculate :sort_numeric, :decimal, expr(fragment("nullif(?->>'value','')::numeric", value))
|
||||
calculate :sort_date, :date, expr(fragment("nullif(?->>'value','')::date", value))
|
||||
calculate :sort_boolean, :boolean, expr(fragment("nullif(?->>'value','')::boolean", value))
|
||||
end
|
||||
|
||||
# Ensure a member can only have one custom field value per custom field
|
||||
|
|
|
|||
|
|
@ -271,6 +271,54 @@ defmodule MvWeb.MemberLive.Index.DateFilter do
|
|||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
DB-side equivalent of the custom-date portion of `apply_in_memory/3`.
|
||||
|
||||
For each active custom-date filter, adds a predicate keeping members that have
|
||||
a stored date value for the field within the inclusive `[from, to]` bounds.
|
||||
The value is read from the JSONB union (`value->>'type' = 'date'`, then
|
||||
`(value->>'value')::date`). Members without a date value row for the field are
|
||||
excluded, mirroring the in-memory behaviour exactly.
|
||||
"""
|
||||
@spec apply_custom_date_ash_filter(Ash.Query.t(), map(), [map()]) :: Ash.Query.t()
|
||||
def apply_custom_date_ash_filter(%Ash.Query{} = query, filters, date_custom_fields)
|
||||
when is_map(filters) and is_list(date_custom_fields) do
|
||||
filters
|
||||
|> active_custom_date_filters(date_custom_fields)
|
||||
|> Enum.reduce(query, fn {id, bounds}, q -> apply_one_custom_date_filter(q, id, bounds) end)
|
||||
end
|
||||
|
||||
defp apply_one_custom_date_filter(query, id, %{from: from, to: to}) do
|
||||
case Ecto.UUID.cast(id) do
|
||||
{:ok, uuid} -> filter_custom_date(query, uuid, from, to)
|
||||
_ -> query
|
||||
end
|
||||
end
|
||||
|
||||
defp filter_custom_date(query, uuid, from, to) do
|
||||
Ash.Query.filter(
|
||||
query,
|
||||
expr(
|
||||
exists(
|
||||
custom_field_values,
|
||||
custom_field_id == ^uuid and
|
||||
fragment("?->>'type' = 'date'", value) and
|
||||
^custom_date_bounds(from, to)
|
||||
)
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
defp custom_date_bounds(nil, to), do: expr(fragment("(?->>'value')::date <= ?", value, ^to))
|
||||
defp custom_date_bounds(from, nil), do: expr(fragment("(?->>'value')::date >= ?", value, ^from))
|
||||
|
||||
defp custom_date_bounds(from, to) do
|
||||
expr(
|
||||
fragment("(?->>'value')::date >= ?", value, ^from) and
|
||||
fragment("(?->>'value')::date <= ?", value, ^to)
|
||||
)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the UUID string keys of `filters` that name an active (at-least-one-
|
||||
bound-set) custom date field. The UUID must appear in `date_custom_fields`
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue