Rework member overview #553
6 changed files with 615 additions and 21 deletions
|
|
@ -266,6 +266,15 @@ defmodule Mv.Membership.Member do
|
||||||
accept [:vereinfacht_contact_id]
|
accept [:vereinfacht_contact_id]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Keyset-paginated read for the member overview. Filters and sort are
|
||||||
|
# composed onto the query by `MvWeb.MemberLive.Index.OverviewQuery`; this
|
||||||
|
# action only enables keyset pagination with a unique `id` tie-breaker so
|
||||||
|
# pages never skip or duplicate rows.
|
||||||
|
read :overview do
|
||||||
|
description "Keyset-paginated member overview read"
|
||||||
|
pagination keyset?: true, default_limit: 50, required?: false
|
||||||
|
end
|
||||||
|
|
||||||
# Action to handle fuzzy search on specific fields
|
# Action to handle fuzzy search on specific fields
|
||||||
read :search do
|
read :search do
|
||||||
argument :query, :string, allow_nil?: true
|
argument :query, :string, allow_nil?: true
|
||||||
|
|
@ -279,27 +288,7 @@ defmodule Mv.Membership.Member do
|
||||||
threshold =
|
threshold =
|
||||||
Ash.Query.get_argument(query, :similarity_threshold) || @default_similarity_threshold
|
Ash.Query.get_argument(query, :similarity_threshold) || @default_similarity_threshold
|
||||||
|
|
||||||
if is_binary(q) and String.trim(q) != "" do
|
apply_fuzzy_search_filter(query, q, threshold)
|
||||||
q2 = String.trim(q)
|
|
||||||
# Sanitize for LIKE patterns (escape % and _), limit length to 100 chars
|
|
||||||
q2_sanitized = sanitize_search_query(q2)
|
|
||||||
pat = "%" <> q2_sanitized <> "%"
|
|
||||||
|
|
||||||
# Build search filters grouped by search type for maintainability
|
|
||||||
# Priority: FTS > Substring > Custom Fields > Fuzzy Matching
|
|
||||||
# Note: FTS and fuzzy use q2 (unsanitized), LIKE-based filters use pat (sanitized)
|
|
||||||
fts_match = build_fts_filter(q2)
|
|
||||||
substring_match = build_substring_filter(q2_sanitized, pat)
|
|
||||||
custom_field_match = build_custom_field_filter(pat)
|
|
||||||
fuzzy_match = build_fuzzy_filter(q2, threshold)
|
|
||||||
|
|
||||||
query
|
|
||||||
|> Ash.Query.filter(
|
|
||||||
expr(^fts_match or ^substring_match or ^custom_field_match or ^fuzzy_match)
|
|
||||||
)
|
|
||||||
else
|
|
||||||
query
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -738,6 +727,13 @@ defmodule Mv.Membership.Member do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
aggregates do
|
||||||
|
# Alphabetically-first group name, used to sort the overview by group DB-side.
|
||||||
|
# Mirrors the previous in-memory "first group name" sort key; members with no
|
||||||
|
# groups yield nil (sorted last ascending via NULLS LAST).
|
||||||
|
min :first_group_name, :groups, :name
|
||||||
|
end
|
||||||
|
|
||||||
# Define identities for upsert operations
|
# Define identities for upsert operations
|
||||||
identities do
|
identities do
|
||||||
identity :unique_email, [:email]
|
identity :unique_email, [:email]
|
||||||
|
|
@ -1212,6 +1208,43 @@ defmodule Mv.Membership.Member do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Applies the same fuzzy/full-text search filter as the `:search` action as a
|
||||||
|
plain filter on the given query, without switching the query's read action.
|
||||||
|
|
||||||
|
Used by the overview query builder so search composes with the `:overview`
|
||||||
|
keyset-paginated read action instead of replacing it.
|
||||||
|
"""
|
||||||
|
@spec apply_overview_search(Ash.Query.t(), String.t() | nil) :: Ash.Query.t()
|
||||||
|
def apply_overview_search(query, q) do
|
||||||
|
apply_fuzzy_search_filter(query, q || "", @default_similarity_threshold)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Shared by the `:search` action and `apply_overview_search/2`.
|
||||||
|
defp apply_fuzzy_search_filter(query, q, threshold) do
|
||||||
|
if is_binary(q) and String.trim(q) != "" do
|
||||||
|
q2 = String.trim(q)
|
||||||
|
# Sanitize for LIKE patterns (escape % and _), limit length to 100 chars
|
||||||
|
q2_sanitized = sanitize_search_query(q2)
|
||||||
|
pat = "%" <> q2_sanitized <> "%"
|
||||||
|
|
||||||
|
# Build search filters grouped by search type for maintainability
|
||||||
|
# Priority: FTS > Substring > Custom Fields > Fuzzy Matching
|
||||||
|
# Note: FTS and fuzzy use q2 (unsanitized), LIKE-based filters use pat (sanitized)
|
||||||
|
fts_match = build_fts_filter(q2)
|
||||||
|
substring_match = build_substring_filter(q2_sanitized, pat)
|
||||||
|
custom_field_match = build_custom_field_filter(pat)
|
||||||
|
fuzzy_match = build_fuzzy_filter(q2, threshold)
|
||||||
|
|
||||||
|
Ash.Query.filter(
|
||||||
|
query,
|
||||||
|
expr(^fts_match or ^substring_match or ^custom_field_match or ^fuzzy_match)
|
||||||
|
)
|
||||||
|
else
|
||||||
|
query
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Search Input Sanitization
|
# Search Input Sanitization
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ defmodule Mv.Membership do
|
||||||
resource Mv.Membership.Member do
|
resource Mv.Membership.Member do
|
||||||
define :create_member, action: :create_member
|
define :create_member, action: :create_member
|
||||||
define :list_members, action: :read
|
define :list_members, action: :read
|
||||||
|
define :overview_members, action: :overview
|
||||||
define :update_member, action: :update_member
|
define :update_member, action: :update_member
|
||||||
define :destroy_member, action: :destroy
|
define :destroy_member, action: :destroy
|
||||||
end
|
end
|
||||||
|
|
|
||||||
261
lib/mv_web/live/member_live/index/overview_query.ex
Normal file
261
lib/mv_web/live/member_live/index/overview_query.ex
Normal file
|
|
@ -0,0 +1,261 @@
|
||||||
|
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
|
||||||
|
|
||||||
|
@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_date_filters(opts[:date_filters])
|
||||||
|
|> apply_cycle_status_filter(
|
||||||
|
opts[:cycle_status_filter],
|
||||||
|
opts[:show_current_cycle],
|
||||||
|
today(opts)
|
||||||
|
)
|
||||||
|
|> apply_sort(opts[:sort_field], opts[:sort_order])
|
||||||
|
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
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 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)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 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, field, order) do
|
||||||
|
case sort_key(field) do
|
||||||
|
nil -> Ash.Query.sort(query, id: :asc)
|
||||||
|
key -> Ash.Query.sort(query, [{key, order}, {:id, :asc}])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# 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
|
||||||
135
test/mv_web/live/member_live/index/overview_query_test.exs
Normal file
135
test/mv_web/live/member_live/index/overview_query_test.exs
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
defmodule MvWeb.MemberLive.Index.OverviewQueryTest do
|
||||||
|
@moduledoc """
|
||||||
|
Resource-level tests for the DB-backed overview query builder: keyset
|
||||||
|
pagination and the standard DB filters/sort pushed down from the LiveView.
|
||||||
|
"""
|
||||||
|
use Mv.DataCase, async: false
|
||||||
|
|
||||||
|
import Mv.Fixtures,
|
||||||
|
only: [member_fixture_with_actor: 2, create_fee_type: 2, group_fixture: 1]
|
||||||
|
|
||||||
|
alias Mv.Membership
|
||||||
|
alias MvWeb.MemberLive.Index.OverviewQuery
|
||||||
|
|
||||||
|
setup do
|
||||||
|
%{actor: Mv.Helpers.SystemActor.get_system_actor()}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp ids(results), do: Enum.map(results, & &1.id) |> MapSet.new()
|
||||||
|
|
||||||
|
defp read_all_pages(query, actor, limit) do
|
||||||
|
page = Ash.read!(query, page: [limit: limit], actor: actor)
|
||||||
|
collect_pages(page, [page.results])
|
||||||
|
end
|
||||||
|
|
||||||
|
defp collect_pages(%{more?: true} = page, acc) do
|
||||||
|
next = Ash.page!(page, :next)
|
||||||
|
collect_pages(next, [next.results | acc])
|
||||||
|
end
|
||||||
|
|
||||||
|
defp collect_pages(_page, acc), do: acc |> Enum.reverse() |> List.flatten()
|
||||||
|
|
||||||
|
describe "keyset pagination" do
|
||||||
|
test "concatenated pages cover the full set once with no duplicates", %{actor: actor} do
|
||||||
|
members =
|
||||||
|
for _ <- 1..5, do: member_fixture_with_actor(%{}, actor)
|
||||||
|
|
||||||
|
expected = ids(members)
|
||||||
|
|
||||||
|
query = OverviewQuery.build(%{sort_field: :inserted_at, sort_order: :asc})
|
||||||
|
all = read_all_pages(query, actor, 2)
|
||||||
|
|
||||||
|
assert ids(all) == expected
|
||||||
|
assert length(all) == length(Enum.uniq_by(all, & &1.id))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "DB filters" do
|
||||||
|
test "search matches name DB-side", %{actor: actor} do
|
||||||
|
hit = member_fixture_with_actor(%{first_name: "Zorblax", last_name: "Quux"}, actor)
|
||||||
|
_miss = member_fixture_with_actor(%{first_name: "Otto", last_name: "Normal"}, actor)
|
||||||
|
|
||||||
|
results =
|
||||||
|
OverviewQuery.build(%{search: "Zorblax"})
|
||||||
|
|> Ash.read!(actor: actor)
|
||||||
|
|
||||||
|
assert hit.id in Enum.map(results, & &1.id)
|
||||||
|
refute Enum.any?(results, &(&1.first_name == "Otto"))
|
||||||
|
end
|
||||||
|
|
||||||
|
test "group :in and :not_in resolve DB-side", %{actor: actor} do
|
||||||
|
group = group_fixture(%{name: "Board #{System.unique_integer([:positive])}"})
|
||||||
|
in_member = member_fixture_with_actor(%{}, actor)
|
||||||
|
out_member = member_fixture_with_actor(%{}, actor)
|
||||||
|
|
||||||
|
{:ok, _} =
|
||||||
|
Membership.create_member_group(%{member_id: in_member.id, group_id: group.id},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
|
opts = %{group_filters: %{to_string(group.id) => :in}, groups: [group]}
|
||||||
|
in_ids = OverviewQuery.build(opts) |> Ash.read!(actor: actor) |> Enum.map(& &1.id)
|
||||||
|
assert in_member.id in in_ids
|
||||||
|
refute out_member.id in in_ids
|
||||||
|
|
||||||
|
not_opts = %{group_filters: %{to_string(group.id) => :not_in}, groups: [group]}
|
||||||
|
out_ids = OverviewQuery.build(not_opts) |> Ash.read!(actor: actor) |> Enum.map(& &1.id)
|
||||||
|
refute in_member.id in out_ids
|
||||||
|
assert out_member.id in out_ids
|
||||||
|
end
|
||||||
|
|
||||||
|
test "fee-type :in resolves DB-side", %{actor: actor} do
|
||||||
|
ft = create_fee_type(%{}, actor)
|
||||||
|
with_ft = member_fixture_with_actor(%{membership_fee_type_id: ft.id}, actor)
|
||||||
|
without_ft = member_fixture_with_actor(%{}, actor)
|
||||||
|
|
||||||
|
opts = %{fee_type_filters: %{to_string(ft.id) => :in}, fee_types: [ft]}
|
||||||
|
result_ids = OverviewQuery.build(opts) |> Ash.read!(actor: actor) |> Enum.map(& &1.id)
|
||||||
|
|
||||||
|
assert with_ft.id in result_ids
|
||||||
|
refute without_ft.id in result_ids
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "group sort (DB aggregate)" do
|
||||||
|
test "sorts by first group name DB-side, members with groups before those without", %{
|
||||||
|
actor: actor
|
||||||
|
} do
|
||||||
|
g_a = group_fixture(%{name: "AAA #{System.unique_integer([:positive])}"})
|
||||||
|
g_z = group_fixture(%{name: "ZZZ #{System.unique_integer([:positive])}"})
|
||||||
|
|
||||||
|
m_a = member_fixture_with_actor(%{}, actor)
|
||||||
|
m_z = member_fixture_with_actor(%{}, actor)
|
||||||
|
m_none = member_fixture_with_actor(%{}, actor)
|
||||||
|
|
||||||
|
{:ok, _} =
|
||||||
|
Membership.create_member_group(%{member_id: m_a.id, group_id: g_a.id}, actor: actor)
|
||||||
|
|
||||||
|
{:ok, _} =
|
||||||
|
Membership.create_member_group(%{member_id: m_z.id, group_id: g_z.id}, actor: actor)
|
||||||
|
|
||||||
|
asc =
|
||||||
|
OverviewQuery.build(%{sort_field: :groups, sort_order: :asc})
|
||||||
|
|> Ash.read!(actor: actor)
|
||||||
|
|> Enum.map(& &1.id)
|
||||||
|
|> Enum.filter(&(&1 in [m_a.id, m_z.id, m_none.id]))
|
||||||
|
|
||||||
|
# group members first (AAA before ZZZ), member without groups last
|
||||||
|
assert asc == [m_a.id, m_z.id, m_none.id]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "sort" do
|
||||||
|
test "sort by last_name is deterministic with id tie-breaker", %{actor: actor} do
|
||||||
|
a = member_fixture_with_actor(%{last_name: "Same"}, actor)
|
||||||
|
b = member_fixture_with_actor(%{last_name: "Same"}, actor)
|
||||||
|
|
||||||
|
results =
|
||||||
|
OverviewQuery.build(%{sort_field: :last_name, sort_order: :asc})
|
||||||
|
|> Ash.read!(actor: actor)
|
||||||
|
|
||||||
|
ordered = results |> Enum.filter(&(&1.id in [a.id, b.id])) |> Enum.map(& &1.id)
|
||||||
|
assert ordered == Enum.sort([a.id, b.id])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
78
test/mv_web/member_live/index_cycle_status_filter_test.exs
Normal file
78
test/mv_web/member_live/index_cycle_status_filter_test.exs
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
defmodule MvWeb.MemberLive.IndexCycleStatusFilterTest do
|
||||||
|
@moduledoc """
|
||||||
|
§1.13 — the paid/unpaid cycle-status filter, under the current or last-completed
|
||||||
|
cycle view, returns the same members as the previous in-memory classifier,
|
||||||
|
computed DB-side via the `cycle_end`-backed aggregates.
|
||||||
|
"""
|
||||||
|
use Mv.DataCase, async: false
|
||||||
|
|
||||||
|
import Mv.Fixtures, only: [create_fee_type: 2, member_fixture_with_actor: 2, create_cycle: 4]
|
||||||
|
|
||||||
|
alias Mv.Membership.Member
|
||||||
|
alias MvWeb.MemberLive.Index.MembershipFeeStatus
|
||||||
|
alias MvWeb.MemberLive.Index.OverviewQuery
|
||||||
|
|
||||||
|
@today ~D[2024-07-15]
|
||||||
|
|
||||||
|
setup do
|
||||||
|
actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
ft = create_fee_type(%{interval: :yearly}, actor)
|
||||||
|
|
||||||
|
# current 2024 paid, last-completed 2023 unpaid
|
||||||
|
paid_now = member_fixture_with_actor(%{membership_fee_type_id: ft.id}, actor)
|
||||||
|
create_cycle(paid_now, ft, %{cycle_start: ~D[2024-01-01], status: :paid}, actor)
|
||||||
|
create_cycle(paid_now, ft, %{cycle_start: ~D[2023-01-01], status: :unpaid}, actor)
|
||||||
|
|
||||||
|
# current 2024 unpaid, last-completed 2023 paid
|
||||||
|
unpaid_now = member_fixture_with_actor(%{membership_fee_type_id: ft.id}, actor)
|
||||||
|
create_cycle(unpaid_now, ft, %{cycle_start: ~D[2024-01-01], status: :unpaid}, actor)
|
||||||
|
create_cycle(unpaid_now, ft, %{cycle_start: ~D[2023-01-01], status: :paid}, actor)
|
||||||
|
|
||||||
|
# no cycles
|
||||||
|
_no_cycles = member_fixture_with_actor(%{membership_fee_type_id: ft.id}, actor)
|
||||||
|
|
||||||
|
%{actor: actor, paid_now: paid_now, unpaid_now: unpaid_now}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp db_ids(status, show_current, actor) do
|
||||||
|
OverviewQuery.build(%{
|
||||||
|
cycle_status_filter: status,
|
||||||
|
show_current_cycle: show_current,
|
||||||
|
today: @today
|
||||||
|
})
|
||||||
|
|> Ash.read!(actor: actor)
|
||||||
|
|> MapSet.new(& &1.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp oracle_ids(status, show_current, actor) do
|
||||||
|
Member
|
||||||
|
|> Ash.Query.load(membership_fee_cycles: [:membership_fee_type])
|
||||||
|
|> Ash.Query.load(:membership_fee_type)
|
||||||
|
|> Ash.read!(actor: actor)
|
||||||
|
|> Enum.filter(fn m ->
|
||||||
|
MembershipFeeStatus.get_cycle_status_for_member(m, show_current, @today) == status
|
||||||
|
end)
|
||||||
|
|> MapSet.new(& &1.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
# {status, show_current, member-key expected in the result}
|
||||||
|
@cases [
|
||||||
|
{:paid, true, :paid_now},
|
||||||
|
{:unpaid, true, :unpaid_now},
|
||||||
|
{:paid, false, :unpaid_now},
|
||||||
|
{:unpaid, false, :paid_now}
|
||||||
|
]
|
||||||
|
|
||||||
|
for {status, show_current, expected_key} <- @cases do
|
||||||
|
test "#{status} under #{if show_current, do: "current", else: "last"} cycle matches oracle",
|
||||||
|
%{actor: actor} = ctx do
|
||||||
|
status = unquote(status)
|
||||||
|
show_current = unquote(show_current)
|
||||||
|
expected = Map.fetch!(ctx, unquote(expected_key))
|
||||||
|
|
||||||
|
db = db_ids(status, show_current, actor)
|
||||||
|
assert db == oracle_ids(status, show_current, actor)
|
||||||
|
assert expected.id in db
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
86
test/mv_web/member_live/index_keyset_property_test.exs
Normal file
86
test/mv_web/member_live/index_keyset_property_test.exs
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
defmodule MvWeb.MemberLive.IndexKeysetPropertyTest do
|
||||||
|
@moduledoc """
|
||||||
|
§2.2 — Keyset pagination completeness: concatenating every keyset page yields
|
||||||
|
exactly the full filtered set once each (no duplicates, no gaps) in a stable
|
||||||
|
total order.
|
||||||
|
|
||||||
|
§2.3 — Sort determinism with the unique `id` tie-breaker: members equal on the
|
||||||
|
sort key are ordered by `id`, and the result set equals the unsorted set.
|
||||||
|
"""
|
||||||
|
use Mv.DataCase, async: false
|
||||||
|
use ExUnitProperties
|
||||||
|
|
||||||
|
import Mv.Fixtures, only: [member_fixture_with_actor: 2]
|
||||||
|
|
||||||
|
alias Mv.Membership.Member
|
||||||
|
alias MvWeb.MemberLive.Index.OverviewQuery
|
||||||
|
|
||||||
|
setup do
|
||||||
|
%{actor: Mv.Helpers.SystemActor.get_system_actor()}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp clear_members(actor) do
|
||||||
|
Member |> Ash.read!(actor: actor) |> Enum.each(&Ash.destroy!(&1, actor: actor))
|
||||||
|
end
|
||||||
|
|
||||||
|
defp read_all_pages(query, actor, limit) do
|
||||||
|
page = Ash.read!(query, page: [limit: limit], actor: actor)
|
||||||
|
collect(page, page.results)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp collect(%{more?: true} = page, acc) do
|
||||||
|
next = Ash.page!(page, :next)
|
||||||
|
collect(next, acc ++ next.results)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp collect(_page, acc), do: acc
|
||||||
|
|
||||||
|
property "keyset pages cover the full set once, no dupes or gaps", %{actor: actor} do
|
||||||
|
check all(
|
||||||
|
n <- integer(1..12),
|
||||||
|
limit <- integer(1..5),
|
||||||
|
sort <- member_of([:last_name, :join_date, :inserted_at]),
|
||||||
|
order <- member_of([:asc, :desc]),
|
||||||
|
max_runs: 25
|
||||||
|
) do
|
||||||
|
clear_members(actor)
|
||||||
|
# Deliberate ties on last_name so the tie-breaker is exercised.
|
||||||
|
members =
|
||||||
|
for i <- 1..n do
|
||||||
|
member_fixture_with_actor(%{last_name: "Tie#{rem(i, 3)}"}, actor)
|
||||||
|
end
|
||||||
|
|
||||||
|
expected = MapSet.new(members, & &1.id)
|
||||||
|
|
||||||
|
query = OverviewQuery.build(%{sort_field: sort, sort_order: order})
|
||||||
|
paged = read_all_pages(query, actor, limit)
|
||||||
|
paged_ids = Enum.map(paged, & &1.id)
|
||||||
|
|
||||||
|
assert MapSet.new(paged_ids) == expected
|
||||||
|
assert length(paged_ids) == MapSet.size(expected)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
property "sort with ties is a deterministic total order broken by id", %{actor: actor} do
|
||||||
|
check all(
|
||||||
|
n <- integer(2..10),
|
||||||
|
order <- member_of([:asc, :desc]),
|
||||||
|
max_runs: 25
|
||||||
|
) do
|
||||||
|
clear_members(actor)
|
||||||
|
|
||||||
|
members =
|
||||||
|
for _ <- 1..n do
|
||||||
|
member_fixture_with_actor(%{last_name: "Same"}, actor)
|
||||||
|
end
|
||||||
|
|
||||||
|
ordered =
|
||||||
|
OverviewQuery.build(%{sort_field: :last_name, sort_order: order})
|
||||||
|
|> Ash.read!(actor: actor)
|
||||||
|
|> Enum.map(& &1.id)
|
||||||
|
|
||||||
|
# All last_names equal -> order is fully determined by the id tie-breaker (asc).
|
||||||
|
assert ordered == Enum.sort(Enum.map(members, & &1.id))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
Loading…
Add table
Add a link
Reference in a new issue