fix(membership): add chronological sort key for custom :date fields

Custom :date values are real Date structs; sorting them by Erlang term
order compares day, then month, then year, so the member list ordered
them like day-first text instead of chronologically. Derive the sort key
from a single shared helper that maps a date to its Gregorian day count,
leaving the other value types at their already-correct natural order.
This commit is contained in:
Moritz 2026-06-15 16:10:14 +02:00
parent d6c322fd79
commit 1aaa0ece5d
3 changed files with 124 additions and 0 deletions

View file

@ -0,0 +1,65 @@
defmodule Mv.Membership.CustomFieldSortPropertyTest do
use ExUnit.Case, async: true
use ExUnitProperties
alias Mv.Membership.CustomFieldSort
defp date_generator do
gen all(
year <- integer(1..9999),
month <- integer(1..12),
day <- integer(1..28)
) do
Date.new!(year, month, day)
end
end
# The production load path always delivers a :date value as
# %Ash.Union{type: :date, value: %Date{}}, so the property exercises both the
# bare %Date{} form and the union-wrapped form to pin the union-dispatch clause.
defp shape_generator do
member_of([:bare, :union])
end
defp wrap_date(date, :bare), do: date
defp wrap_date(date, :union), do: %Ash.Union{type: :date, value: date}
property "sort_key/2 orders :date values chronologically in both directions" do
check all(
raw_dates <- list_of(date_generator(), min_length: 1),
shape <- shape_generator()
) do
values = Enum.map(raw_dates, &wrap_date(&1, shape))
ascending = Enum.sort_by(values, &CustomFieldSort.sort_key(&1, :date))
ascending_dates = Enum.map(ascending, &unwrap/1)
descending_dates = Enum.reverse(ascending_dates)
assert non_decreasing?(ascending_dates)
assert non_increasing?(descending_dates)
# The key must be an integer Gregorian-day count, not a stringified date:
# this pins the dedicated :date branch and guards against a string-coerced
# key whose chronological correctness would silently depend on zero-padded
# ISO formatting.
Enum.each(values, fn value ->
assert CustomFieldSort.sort_key(value, :date) == Date.to_gregorian_days(unwrap(value))
end)
end
end
defp unwrap(%Ash.Union{value: value}), do: value
defp unwrap(%Date{} = date), do: date
defp non_decreasing?(dates) do
dates
|> Enum.chunk_every(2, 1, :discard)
|> Enum.all?(fn [a, b] -> Date.compare(a, b) != :gt end)
end
defp non_increasing?(dates) do
dates
|> Enum.chunk_every(2, 1, :discard)
|> Enum.all?(fn [a, b] -> Date.compare(a, b) != :lt end)
end
end

View file

@ -0,0 +1,29 @@
defmodule Mv.Membership.CustomFieldSortTest do
use ExUnit.Case, async: true
alias Mv.Membership.CustomFieldSort
describe "sort_key/2" do
test "keeps :integer values numerically comparable" do
values = [10, 100, 2]
sorted = Enum.sort_by(values, &CustomFieldSort.sort_key(&1, :integer))
assert sorted == [2, 10, 100]
end
test "passes :string values through to their natural term-order key" do
assert CustomFieldSort.sort_key("Zebra", :string) == "Zebra"
end
test "passes :email values through to their natural term-order key" do
assert CustomFieldSort.sort_key("a@example.com", :email) == "a@example.com"
end
test "unwraps an %Ash.Union{} value before deriving the key" do
union = %Ash.Union{type: :integer, value: 42}
assert CustomFieldSort.sort_key(union, :integer) == 42
end
end
end