feat(date-filter): introduce DateFilter module with URL codec and Ash query expressions

This commit is contained in:
Moritz 2026-05-20 16:24:08 +02:00
parent 143c0c5c24
commit ddd4a9a878
7 changed files with 1195 additions and 0 deletions

View file

@ -0,0 +1,210 @@
defmodule MvWeb.MemberLive.Index.DateFilterCustomFieldTest do
@moduledoc """
Unit tests for `DateFilter.apply_in_memory/3` the post-`Ash.read!`
predicate that filters members by custom date field values stored as
JSONB `Ash.Union` types in `custom_field_values`.
"""
use ExUnit.Case, async: true
alias MvWeb.MemberLive.Index.DateFilter
# ---- helpers ---------------------------------------------------------
defp date_custom_field(id, name \\ "Birthday") do
%{id: id, value_type: :date, name: name}
end
defp date_cfv(custom_field_id, %Date{} = date) do
%{
custom_field_id: custom_field_id,
value: %Ash.Union{value: date, type: :date}
}
end
defp member_with_dates(id, custom_field_values) do
%{id: id, custom_field_values: custom_field_values}
end
# ---- no-op cases -----------------------------------------------------
describe "apply_in_memory/3 — no-op cases" do
test "returns members unchanged when filters has no custom date entries" do
filters = DateFilter.default()
members = [member_with_dates("m1", []), member_with_dates("m2", [])]
assert DateFilter.apply_in_memory(members, filters, []) == members
end
test "ignores custom date entries whose UUID is not in the date_custom_fields list" do
id = "11111111-2222-3333-4444-555555555555"
other_id = "99999999-8888-7777-6666-555555555555"
filters =
DateFilter.default()
|> Map.put(other_id, %{from: ~D[2024-01-01], to: nil})
m = member_with_dates("m1", [date_cfv(id, ~D[2023-01-01])])
assert DateFilter.apply_in_memory([m], filters, [date_custom_field(id)]) == [m]
end
test "entry with both bounds nil is treated as inactive" do
id = "11111111-2222-3333-4444-555555555555"
filters =
DateFilter.default()
|> Map.put(id, %{from: nil, to: nil})
m = member_with_dates("m1", [date_cfv(id, ~D[2023-01-01])])
assert DateFilter.apply_in_memory([m], filters, [date_custom_field(id)]) == [m]
end
end
# ---- inclusive range semantics --------------------------------------
describe "apply_in_memory/3 — inclusive range semantics" do
setup do
id = "11111111-2222-3333-4444-555555555555"
members = [
member_with_dates("before", [date_cfv(id, ~D[2024-05-31])]),
member_with_dates("from_boundary", [date_cfv(id, ~D[2024-06-01])]),
member_with_dates("inside", [date_cfv(id, ~D[2024-06-15])]),
member_with_dates("to_boundary", [date_cfv(id, ~D[2024-06-30])]),
member_with_dates("after", [date_cfv(id, ~D[2024-07-01])])
]
%{id: id, members: members, fields: [date_custom_field(id)]}
end
test "from-only includes member when value >= from (boundary inclusive)", ctx do
filters =
DateFilter.default()
|> Map.put(ctx.id, %{from: ~D[2024-06-01], to: nil})
ids =
ctx.members
|> DateFilter.apply_in_memory(filters, ctx.fields)
|> Enum.map(& &1.id)
assert ids == ["from_boundary", "inside", "to_boundary", "after"]
end
test "from-only excludes member when value < from", ctx do
filters =
DateFilter.default()
|> Map.put(ctx.id, %{from: ~D[2024-06-01], to: nil})
refute Enum.any?(
DateFilter.apply_in_memory(ctx.members, filters, ctx.fields),
&(&1.id == "before")
)
end
test "to-only includes member when value <= to (boundary inclusive)", ctx do
filters =
DateFilter.default()
|> Map.put(ctx.id, %{from: nil, to: ~D[2024-06-30]})
ids =
ctx.members
|> DateFilter.apply_in_memory(filters, ctx.fields)
|> Enum.map(& &1.id)
assert ids == ["before", "from_boundary", "inside", "to_boundary"]
end
test "from+to applies an inclusive range", ctx do
filters =
DateFilter.default()
|> Map.put(ctx.id, %{from: ~D[2024-06-01], to: ~D[2024-06-30]})
ids =
ctx.members
|> DateFilter.apply_in_memory(filters, ctx.fields)
|> Enum.map(& &1.id)
assert ids == ["from_boundary", "inside", "to_boundary"]
end
end
# ---- exclusion of members without a value ---------------------------
describe "apply_in_memory/3 — members without a value" do
test "excludes member with no custom_field_values when bound is active" do
id = "11111111-2222-3333-4444-555555555555"
filters =
DateFilter.default()
|> Map.put(id, %{from: ~D[2024-01-01], to: nil})
members = [
member_with_dates("present", [date_cfv(id, ~D[2024-06-01])]),
member_with_dates("nil_list", nil),
member_with_dates("empty_list", []),
# member missing the specific field but having other CFVs:
member_with_dates("other_field", [
date_cfv("other-aaaa-bbbb-cccc-dddddddddddd", ~D[2024-06-01])
])
]
ids =
members
|> DateFilter.apply_in_memory(filters, [date_custom_field(id)])
|> Enum.map(& &1.id)
assert ids == ["present"]
end
test "treats Ash.NotLoaded custom_field_values as no value (excluded)" do
id = "11111111-2222-3333-4444-555555555555"
filters =
DateFilter.default()
|> Map.put(id, %{from: ~D[2024-01-01], to: nil})
m = %{id: "m1", custom_field_values: %Ash.NotLoaded{}}
assert DateFilter.apply_in_memory([m], filters, [date_custom_field(id)]) == []
end
end
# ---- Ash.Union unwrapping -------------------------------------------
describe "apply_in_memory/3 — Ash.Union unwrapping" do
test "unwraps %Ash.Union{value: %Date{}, type: :date}" do
id = "11111111-2222-3333-4444-555555555555"
filters =
DateFilter.default()
|> Map.put(id, %{from: ~D[2024-06-01], to: nil})
m =
member_with_dates("m1", [
%{
custom_field_id: id,
value: %Ash.Union{value: ~D[2024-06-15], type: :date}
}
])
assert DateFilter.apply_in_memory([m], filters, [date_custom_field(id)]) == [m]
end
test "rejects values whose Ash.Union type is not :date" do
id = "11111111-2222-3333-4444-555555555555"
filters =
DateFilter.default()
|> Map.put(id, %{from: ~D[2024-06-01], to: nil})
# A boolean-typed value for what the filter believes is a date field —
# treat as no value, exclude the member.
m =
member_with_dates("m1", [
%{
custom_field_id: id,
value: %Ash.Union{value: true, type: :boolean}
}
])
assert DateFilter.apply_in_memory([m], filters, [date_custom_field(id)]) == []
end
end
end

View file

@ -0,0 +1,24 @@
defmodule MvWeb.MemberLive.Index.DateFilterDefaultTest do
@moduledoc """
Unit tests for DateFilter.default/0 the initial filter map used when
no URL params are present (fresh load) and after "Clear filters".
"""
use ExUnit.Case, async: true
alias MvWeb.MemberLive.Index.DateFilter
describe "default/0" do
test "returns :active_only mode for exit_date with nil bounds" do
assert %{exit_date: %{mode: :active_only, from: nil, to: nil}} = DateFilter.default()
end
test "returns nil bounds for join_date" do
assert %{join_date: %{from: nil, to: nil}} = DateFilter.default()
end
test "contains only :join_date and :exit_date top-level keys" do
defaults = DateFilter.default()
assert Map.keys(defaults) |> Enum.sort() == [:exit_date, :join_date]
end
end
end

View file

@ -0,0 +1,134 @@
defmodule MvWeb.MemberLive.Index.DateFilterPropertyTest do
@moduledoc """
Property tests for the pure functions on `DateFilter`:
* `to_params/1` `from_params/2` must be the identity for all valid
built-in date filter states (§2.3).
Custom date field entries are not part of this property because
`from_params/2` needs the caller-supplied `date_custom_fields` list to
validate UUIDs; the standalone property for the in-memory predicate (§2.4)
is covered in S8 after the predicate exists.
"""
use ExUnit.Case, async: true
use ExUnitProperties
alias MvWeb.MemberLive.Index.DateFilter
# Generators -----------------------------------------------------------
defp optional_date_gen do
one_of([
constant(nil),
map(integer(-3650..3650), &Date.add(~D[2000-01-01], &1))
])
end
defp exit_date_mode_gen do
one_of([
constant(:active_only),
constant(:all),
constant(:inactive_only),
constant(:custom)
])
end
defp exit_date_state_gen do
gen all(
mode <- exit_date_mode_gen(),
from <- optional_date_gen(),
to <- optional_date_gen()
) do
%{mode: mode, from: from, to: to}
end
end
defp join_date_state_gen do
gen all(
from <- optional_date_gen(),
to <- optional_date_gen()
) do
%{from: from, to: to}
end
end
# Property -------------------------------------------------------------
defp bound_pair_with_at_least_one_set_gen do
gen all(
from <- optional_date_gen(),
to <- optional_date_gen(),
from != nil or to != nil
) do
{from, to}
end
end
defp value_date_gen do
map(integer(-3650..3650), &Date.add(~D[2000-01-01], &1))
end
# Property -------------------------------------------------------------
property "in-memory date filter matches the inclusive range predicate" do
id = "11111111-2222-3333-4444-555555555555"
field = %{id: id, value_type: :date, name: "Property field"}
check all(
value <- value_date_gen(),
{from, to} <- bound_pair_with_at_least_one_set_gen()
) do
filters =
DateFilter.default()
|> Map.put(id, %{from: from, to: to})
member = %{
id: "m1",
custom_field_values: [
%{
custom_field_id: id,
value: %Ash.Union{value: value, type: :date}
}
]
}
result = DateFilter.apply_in_memory([member], filters, [field])
from_ok? = is_nil(from) or Date.compare(value, from) != :lt
to_ok? = is_nil(to) or Date.compare(value, to) != :gt
expected_included? = from_ok? and to_ok?
actually_included? = result == [member]
assert actually_included? == expected_included?
end
end
property "encoding then decoding built-in date filter state is identity" do
check all(
join_date <- join_date_state_gen(),
exit_date <- exit_date_state_gen()
) do
filters = %{join_date: join_date, exit_date: exit_date}
decoded = DateFilter.from_params(DateFilter.to_params(filters), [])
# join_date round-trips verbatim.
assert decoded.join_date == join_date
# exit_date semantics:
# * :active_only is the default and discards bounds — the canonical
# URL omits them, so decoding restores nil bounds.
# * :all and :inactive_only also drop bounds in the URL — same reason.
# * :custom preserves bounds.
expected_exit_date =
case exit_date.mode do
:active_only -> %{mode: :active_only, from: nil, to: nil}
:all -> %{mode: :all, from: nil, to: nil}
:inactive_only -> %{mode: :inactive_only, from: nil, to: nil}
:custom -> exit_date
end
assert decoded.exit_date == expected_exit_date
end
end
end

View file

@ -0,0 +1,316 @@
defmodule MvWeb.MemberLive.Index.DateFilterTest do
@moduledoc """
Unit tests for DateFilter URL codec and pure helpers.
DB-level filtering and in-memory custom field filtering are covered in
separate integration tests (date_filter_default_test, date_filter_test,
date_filter_custom_field_test).
"""
use ExUnit.Case, async: true
alias MvWeb.MemberLive.Index.DateFilter
# Synthesize the minimal shape of a date-typed custom field expected by
# from_params/2. Only :id and :value_type are inspected by the decoder.
defp date_custom_field(id) do
%{id: id, value_type: :date, name: "Birthday-#{id}"}
end
describe "from_params/2 — built-in date fields" do
test "parses jd_from as a Date" do
params = %{"jd_from" => "2024-05-01"}
filters = DateFilter.from_params(params, [])
assert filters.join_date.from == ~D[2024-05-01]
assert filters.join_date.to == nil
end
test "parses jd_to as a Date" do
params = %{"jd_to" => "2024-08-31"}
filters = DateFilter.from_params(params, [])
assert filters.join_date.to == ~D[2024-08-31]
assert filters.join_date.from == nil
end
test "ignores malformed jd_from string" do
params = %{"jd_from" => "notadate"}
filters = DateFilter.from_params(params, [])
assert filters.join_date.from == nil
assert filters.join_date.to == nil
end
test "ignores malformed jd_to string" do
params = %{"jd_to" => "2024-13-45"}
filters = DateFilter.from_params(params, [])
assert filters.join_date.to == nil
end
test "parses ed_mode=all" do
params = %{"ed_mode" => "all"}
filters = DateFilter.from_params(params, [])
assert filters.exit_date.mode == :all
end
test "parses ed_mode=inactive_only" do
params = %{"ed_mode" => "inactive_only"}
filters = DateFilter.from_params(params, [])
assert filters.exit_date.mode == :inactive_only
end
test "parses ed_mode=custom with bounds" do
params = %{"ed_mode" => "custom", "ed_from" => "2024-01-01", "ed_to" => "2024-12-31"}
filters = DateFilter.from_params(params, [])
assert filters.exit_date.mode == :custom
assert filters.exit_date.from == ~D[2024-01-01]
assert filters.exit_date.to == ~D[2024-12-31]
end
test "returns :active_only mode when ed_mode is absent" do
filters = DateFilter.from_params(%{}, [])
assert filters.exit_date.mode == :active_only
end
test "treats unknown ed_mode value as :active_only" do
params = %{"ed_mode" => "gibberish"}
filters = DateFilter.from_params(params, [])
assert filters.exit_date.mode == :active_only
end
end
describe "to_params/1 — built-in date fields" do
test "omits ed_mode when mode is :active_only (default)" do
params = DateFilter.to_params(DateFilter.default())
refute Map.has_key?(params, "ed_mode")
refute Map.has_key?(params, "ed_from")
refute Map.has_key?(params, "ed_to")
refute Map.has_key?(params, "jd_from")
refute Map.has_key?(params, "jd_to")
end
test "encodes ed_mode=all" do
filters = %{
join_date: %{from: nil, to: nil},
exit_date: %{mode: :all, from: nil, to: nil}
}
params = DateFilter.to_params(filters)
assert params["ed_mode"] == "all"
end
test "encodes ed_mode=inactive_only" do
filters = %{
join_date: %{from: nil, to: nil},
exit_date: %{mode: :inactive_only, from: nil, to: nil}
}
params = DateFilter.to_params(filters)
assert params["ed_mode"] == "inactive_only"
end
test "encodes ed_mode=custom with bounds" do
filters = %{
join_date: %{from: nil, to: nil},
exit_date: %{mode: :custom, from: ~D[2024-01-01], to: ~D[2024-12-31]}
}
params = DateFilter.to_params(filters)
assert params["ed_mode"] == "custom"
assert params["ed_from"] == "2024-01-01"
assert params["ed_to"] == "2024-12-31"
end
test "encodes jd_from as ISO-8601 string" do
filters = %{
join_date: %{from: ~D[2024-05-01], to: nil},
exit_date: %{mode: :active_only, from: nil, to: nil}
}
params = DateFilter.to_params(filters)
assert params["jd_from"] == "2024-05-01"
refute Map.has_key?(params, "jd_to")
end
test "encodes jd_to as ISO-8601 string" do
filters = %{
join_date: %{from: nil, to: ~D[2024-08-31]},
exit_date: %{mode: :active_only, from: nil, to: nil}
}
params = DateFilter.to_params(filters)
assert params["jd_to"] == "2024-08-31"
refute Map.has_key?(params, "jd_from")
end
test "omits exit_date bounds when mode is not :custom" do
# Bounds may linger in state for UX (preserve user's last input) but the
# URL should not advertise them while a non-custom mode is active.
filters = %{
join_date: %{from: nil, to: nil},
exit_date: %{mode: :all, from: ~D[2024-01-01], to: ~D[2024-12-31]}
}
params = DateFilter.to_params(filters)
assert params["ed_mode"] == "all"
refute Map.has_key?(params, "ed_from")
refute Map.has_key?(params, "ed_to")
end
end
describe "to_params/1 — custom date field entries" do
test "encodes from/to bounds with cdf_<uuid>_ prefix" do
id = "11111111-2222-3333-4444-555555555555"
filters =
DateFilter.default()
|> Map.put(id, %{from: ~D[2024-06-01], to: ~D[2024-06-30]})
params = DateFilter.to_params(filters)
assert params["cdf_#{id}_from"] == "2024-06-01"
assert params["cdf_#{id}_to"] == "2024-06-30"
end
test "omits nil bounds for custom date field entries" do
id = "11111111-2222-3333-4444-555555555555"
filters =
DateFilter.default()
|> Map.put(id, %{from: ~D[2024-06-01], to: nil})
params = DateFilter.to_params(filters)
assert params["cdf_#{id}_from"] == "2024-06-01"
refute Map.has_key?(params, "cdf_#{id}_to")
end
test "omits custom date field entry entirely when both bounds are nil" do
id = "11111111-2222-3333-4444-555555555555"
filters =
DateFilter.default()
|> Map.put(id, %{from: nil, to: nil})
params = DateFilter.to_params(filters)
refute Map.has_key?(params, "cdf_#{id}_from")
refute Map.has_key?(params, "cdf_#{id}_to")
end
end
describe "apply_ash_filter/2 — shape contract" do
# The behavioral correctness of apply_ash_filter/2 is verified in the
# integration tests (date_filter_default_test, date_filter_test). Here we
# only assert the shape contract: which inputs leave the query untouched,
# and which add a filter expression. Inspecting Ash internals is brittle —
# we only check `query.filter` is or is not nil.
alias Mv.Membership.Member, as: MemberResource
defp base_query, do: MemberResource
test ":all mode and empty join_date is a no-op" do
filters = %{
join_date: %{from: nil, to: nil},
exit_date: %{mode: :all, from: nil, to: nil}
}
query = DateFilter.apply_ash_filter(base_query(), filters)
# No filter was applied — Ash leaves filter as nil when nothing is added.
assert is_nil(query.filter)
end
test "default (:active_only) adds an exit_date filter" do
query = DateFilter.apply_ash_filter(base_query(), DateFilter.default())
refute is_nil(query.filter)
end
test ":inactive_only adds an exit_date filter" do
filters = %{
join_date: %{from: nil, to: nil},
exit_date: %{mode: :inactive_only, from: nil, to: nil}
}
query = DateFilter.apply_ash_filter(base_query(), filters)
refute is_nil(query.filter)
end
test ":custom mode with bounds adds an exit_date filter" do
filters = %{
join_date: %{from: nil, to: nil},
exit_date: %{mode: :custom, from: ~D[2024-01-01], to: ~D[2024-12-31]}
}
query = DateFilter.apply_ash_filter(base_query(), filters)
refute is_nil(query.filter)
end
test ":custom mode with both bounds nil adds no filter" do
filters = %{
join_date: %{from: nil, to: nil},
exit_date: %{mode: :custom, from: nil, to: nil}
}
query = DateFilter.apply_ash_filter(base_query(), filters)
assert is_nil(query.filter)
end
test "join_date from adds a filter" do
filters = %{
join_date: %{from: ~D[2024-01-01], to: nil},
exit_date: %{mode: :all, from: nil, to: nil}
}
query = DateFilter.apply_ash_filter(base_query(), filters)
refute is_nil(query.filter)
end
test "join_date to adds a filter" do
filters = %{
join_date: %{from: nil, to: ~D[2024-12-31]},
exit_date: %{mode: :all, from: nil, to: nil}
}
query = DateFilter.apply_ash_filter(base_query(), filters)
refute is_nil(query.filter)
end
test "accepts a non-resource Ash.Query as input" do
filters = %{
join_date: %{from: nil, to: nil},
exit_date: %{mode: :inactive_only, from: nil, to: nil}
}
# Should also work when given a pre-built Ash.Query (not just a resource).
query =
MemberResource
|> Ash.Query.new()
|> DateFilter.apply_ash_filter(filters)
refute is_nil(query.filter)
end
end
describe "from_params/2 — custom date field entries" do
test "includes entry for known custom date field UUID" do
id = "11111111-2222-3333-4444-555555555555"
params = %{"cdf_#{id}_from" => "2024-06-01", "cdf_#{id}_to" => "2024-06-30"}
filters = DateFilter.from_params(params, [date_custom_field(id)])
assert filters[id] == %{from: ~D[2024-06-01], to: ~D[2024-06-30]}
end
test "ignores UUID not in date_custom_fields list" do
id = "11111111-2222-3333-4444-555555555555"
other = "99999999-8888-7777-6666-555555555555"
params = %{"cdf_#{other}_from" => "2024-06-01"}
filters = DateFilter.from_params(params, [date_custom_field(id)])
refute Map.has_key?(filters, other)
end
test "ignores malformed custom date field bound" do
id = "11111111-2222-3333-4444-555555555555"
params = %{"cdf_#{id}_from" => "notadate"}
filters = DateFilter.from_params(params, [date_custom_field(id)])
# Either no entry, or entry with nil bounds — both satisfy "silently ignored"
case Map.get(filters, id) do
nil -> :ok
%{from: nil, to: nil} -> :ok
other -> flunk("expected nil bound for malformed input, got #{inspect(other)}")
end
end
end
end