From 1aaa0ece5df51b2d1f3cc8043de78ed857a653c7 Mon Sep 17 00:00:00 2001 From: Moritz Date: Mon, 15 Jun 2026 16:10:14 +0200 Subject: [PATCH] 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. --- lib/mv/membership/custom_field_sort.ex | 30 +++++++++ .../custom_field_sort_property_test.exs | 65 +++++++++++++++++++ test/mv/membership/custom_field_sort_test.exs | 29 +++++++++ 3 files changed, 124 insertions(+) create mode 100644 lib/mv/membership/custom_field_sort.ex create mode 100644 test/mv/membership/custom_field_sort_property_test.exs create mode 100644 test/mv/membership/custom_field_sort_test.exs diff --git a/lib/mv/membership/custom_field_sort.ex b/lib/mv/membership/custom_field_sort.ex new file mode 100644 index 0000000..45eaf4b --- /dev/null +++ b/lib/mv/membership/custom_field_sort.ex @@ -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 diff --git a/test/mv/membership/custom_field_sort_property_test.exs b/test/mv/membership/custom_field_sort_property_test.exs new file mode 100644 index 0000000..345a299 --- /dev/null +++ b/test/mv/membership/custom_field_sort_property_test.exs @@ -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 diff --git a/test/mv/membership/custom_field_sort_test.exs b/test/mv/membership/custom_field_sort_test.exs new file mode 100644 index 0000000..6d8a8e6 --- /dev/null +++ b/test/mv/membership/custom_field_sort_test.exs @@ -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