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:
parent
d6c322fd79
commit
1aaa0ece5d
3 changed files with 124 additions and 0 deletions
30
lib/mv/membership/custom_field_sort.ex
Normal file
30
lib/mv/membership/custom_field_sort.ex
Normal 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
|
||||
65
test/mv/membership/custom_field_sort_property_test.exs
Normal file
65
test/mv/membership/custom_field_sort_property_test.exs
Normal 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
|
||||
29
test/mv/membership/custom_field_sort_test.exs
Normal file
29
test/mv/membership/custom_field_sort_test.exs
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue