Fix sort by custom date closes #496 #528

Merged
moritz merged 4 commits from issue/mitgliederverwaltung-496 into main 2026-06-15 16:34:41 +02:00
3 changed files with 124 additions and 0 deletions
Showing only changes of commit 1aaa0ece5d - Show all commits

View file

@ -0,0 +1,30 @@
defmodule Mv.Membership.CustomFieldSort do
@moduledoc """
Derives a term-order-comparable sort key from a custom-field value.
Custom-field values are stored in an Ash `:union` attribute, so a value
arrives either as an `%Ash.Union{}` or as its already-unwrapped term. This
module is the single source of truth for turning such a value into a key that
`Enum.sort_by/2` can order correctly per `value_type`.
nil / empty handling is intentionally NOT this function's concern — the call
sites split present from absent values before sorting.
"""
@doc """
Returns a term-order-comparable sort key for `value`, given its `value_type`.
For every non-date type the natural unwrapped value is returned, so
`:integer` sorts numerically and `:string` / `:email` sort lexicographically.
"""
def sort_key(%Ash.Union{value: value, type: type}, _expected_type),
do: sort_key(value, type)
def sort_key(value, :string) when is_binary(value), do: value
def sort_key(value, :integer) when is_integer(value), do: value
def sort_key(value, :boolean) when is_boolean(value), do: value
# Gregorian day count is a chronological integer key (earlier date ⇒ smaller).
def sort_key(%Date{} = date, :date), do: Date.to_gregorian_days(date)
def sort_key(value, :email) when is_binary(value), do: value
def sort_key(value, _type), do: to_string(value)
end

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