From 79ab918594504341304dd7b6edf66053caab9605 Mon Sep 17 00:00:00 2001 From: Joseph Koski Date: Fri, 23 Jan 2026 13:35:20 -0800 Subject: [PATCH 1/3] Remove extra funcs --- lib/eq.ex | 20 ------ lib/macros.ex | 30 ++++++++- lib/ord.ex | 23 ------- test/macros_test.exs | 146 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 174 insertions(+), 45 deletions(-) diff --git a/lib/eq.ex b/lib/eq.ex index 6b1e0fdd..8b91363a 100644 --- a/lib/eq.ex +++ b/lib/eq.ex @@ -266,26 +266,6 @@ defmodule Funx.Eq do } end - @doc """ - Converts an Eq DSL result or projection to an eq_map. - - If passed a plain map with `eq?/2` and `not_eq?/2` functions (the result - of `eq do ... end`), returns it directly. Otherwise, delegates to `contramap/2`. - - Used internally by `Funx.Macros.eq_for/3` to support both projection-based - and DSL-based equality definitions. - """ - @spec to_eq_map_or_contramap(any(), eq_t()) :: eq_map() - # Plain map with eq?/not_eq? keys (DSL result) - def to_eq_map_or_contramap(%{eq?: eq?, not_eq?: not_eq?} = map, _eq) - when is_function(eq?, 2) and is_function(not_eq?, 2) and not is_struct(map) do - map - end - - def to_eq_map_or_contramap(projection, eq) do - contramap(projection, eq) - end - @doc """ Checks equality of two values by applying a projection before comparison. diff --git a/lib/macros.ex b/lib/macros.ex index 2dab4374..1c4aa1da 100644 --- a/lib/macros.ex +++ b/lib/macros.ex @@ -280,7 +280,18 @@ defmodule Funx.Macros do defimpl Funx.Eq.Protocol, for: unquote(for_struct) do # Private function to build the eq_map once at module compile time defp __eq_map__ do - Funx.Eq.to_eq_map_or_contramap(unquote(projection_ast), unquote(eq_module_ast)) + __resolve_eq_map__(unquote(projection_ast), unquote(eq_module_ast)) + end + + # Eq map (plain map or Monoid.Eq struct) - use directly + defp __resolve_eq_map__(%{eq?: eq_fun, not_eq?: not_eq_fun} = map, _eq) + when is_function(eq_fun, 2) and is_function(not_eq_fun, 2) do + map + end + + # Projection - wrap in contramap + defp __resolve_eq_map__(projection, eq) do + Funx.Eq.contramap(projection, eq) end def eq?(a, b) @@ -372,7 +383,22 @@ defmodule Funx.Macros do defimpl Funx.Ord.Protocol, for: unquote(for_struct) do # Private function to build the ord_map once at module compile time defp __ord_map__ do - Funx.Ord.to_ord_map_or_contramap(unquote(projection_ast), unquote(ord_module_ast)) + __resolve_ord_map__(unquote(projection_ast), unquote(ord_module_ast)) + end + + # Ord map (plain map or Monoid.Ord struct) - use directly + defp __resolve_ord_map__( + %{lt?: lt_fun, le?: le_fun, gt?: gt_fun, ge?: ge_fun} = map, + _ord + ) + when is_function(lt_fun, 2) and is_function(le_fun, 2) and + is_function(gt_fun, 2) and is_function(ge_fun, 2) do + map + end + + # Projection - wrap in contramap + defp __resolve_ord_map__(projection, ord) do + Funx.Ord.contramap(projection, ord) end def lt?(a, b) diff --git a/lib/ord.ex b/lib/ord.ex index 0d381a72..9f00fe96 100644 --- a/lib/ord.ex +++ b/lib/ord.ex @@ -222,29 +222,6 @@ defmodule Funx.Ord do } end - @doc """ - Converts an Ord DSL result or projection to an ord_map. - - If passed a plain map with `lt?/2`, `le?/2`, `gt?/2`, and `ge?/2` functions - (the result of `ord do ... end`), returns it directly. Otherwise, delegates - to `contramap/2`. - - Used internally by `Funx.Macros.ord_for/3` to support both projection-based - and DSL-based ordering definitions. - """ - @spec to_ord_map_or_contramap(any(), ord_t()) :: ord_map() - - # Plain map with ord keys (DSL result) - def to_ord_map_or_contramap(%{lt?: lt?, le?: le?, gt?: gt?, ge?: ge?} = map, _ord) - when is_function(lt?, 2) and is_function(le?, 2) and is_function(gt?, 2) and - is_function(ge?, 2) and not is_struct(map) do - map - end - - def to_ord_map_or_contramap(projection, ord) do - contramap(projection, ord) - end - @doc """ Returns the maximum of two values, with an optional custom `Ord`. diff --git a/test/macros_test.exs b/test/macros_test.exs index c530a267..4d38f0a7 100644 --- a/test/macros_test.exs +++ b/test/macros_test.exs @@ -550,6 +550,152 @@ defmodule Funx.MacrosTest do end end + # ============================================================================ + # DSL via Function Call Tests (ord_for/eq_for with function returning DSL result) + # ============================================================================ + + # Helper module for defining ord/eq functions + defmodule OrdEqHelpers do + @moduledoc false + use Funx.Ord + use Funx.Eq + + alias Funx.Optics.Lens + + def card_ord do + ord do + asc Lens.key(:color) + asc Lens.key(:value) + end + end + + def person_eq do + eq do + on :name + on :age + end + end + + def reverse_priority_ord do + ord do + desc :priority + asc :title + end + end + end + + defmodule FnOrdCard do + @moduledoc false + defstruct [:color, :value, :suit] + + # Using function call that returns an Ord map from DSL + Funx.Macros.ord_for(FnOrdCard, OrdEqHelpers.card_ord()) + end + + defmodule FnEqEmployee do + @moduledoc false + defstruct [:name, :age, :department] + + # Using function call that returns an Eq map from DSL + Funx.Macros.eq_for(FnEqEmployee, OrdEqHelpers.person_eq()) + end + + defmodule FnOrdTask do + @moduledoc false + defstruct [:title, :priority, :status] + + # Using function call with desc ordering + Funx.Macros.ord_for(FnOrdTask, OrdEqHelpers.reverse_priority_ord()) + end + + describe "ord_for with function returning Ord DSL result" do + test "compares using DSL from function call" do + card1 = %FnOrdCard{color: :red, value: 5, suit: :hearts} + card2 = %FnOrdCard{color: :red, value: 10, suit: :diamonds} + card3 = %FnOrdCard{color: :blue, value: 1, suit: :clubs} + + # Same color, different value: 5 < 10 + assert Protocol.lt?(card1, card2) + + # Different color: :blue < :red (atom ordering) + assert Protocol.lt?(card3, card1) + end + + test "uses secondary sort key from function-returned DSL" do + card1 = %FnOrdCard{color: :red, value: 5, suit: :hearts} + card2 = %FnOrdCard{color: :red, value: 5, suit: :diamonds} + + # Same color and value - should be equal + assert Protocol.le?(card1, card2) + assert Protocol.ge?(card1, card2) + end + + test "sorts list using function-returned DSL ordering" do + cards = [ + %FnOrdCard{color: :red, value: 10, suit: :hearts}, + %FnOrdCard{color: :blue, value: 5, suit: :clubs}, + %FnOrdCard{color: :red, value: 3, suit: :diamonds}, + %FnOrdCard{color: :blue, value: 8, suit: :spades} + ] + + sorted = Enum.sort(cards, &Protocol.le?/2) + + # First by color (:blue < :red), then by value + assert Enum.map(sorted, &{&1.color, &1.value}) == [ + {:blue, 5}, + {:blue, 8}, + {:red, 3}, + {:red, 10} + ] + end + + test "desc ordering from function-returned DSL" do + task1 = %FnOrdTask{title: "A", priority: 1, status: :pending} + task2 = %FnOrdTask{title: "B", priority: 5, status: :done} + + # Higher priority should come first (desc), so task2 < task1 in sort order + assert Protocol.lt?(task2, task1) + assert Protocol.gt?(task1, task2) + end + + test "secondary asc sort with desc primary from function" do + task1 = %FnOrdTask{title: "Zebra", priority: 3, status: :pending} + task2 = %FnOrdTask{title: "Alpha", priority: 3, status: :done} + + # Same priority, sort by title asc: Alpha < Zebra + assert Protocol.lt?(task2, task1) + end + end + + describe "eq_for with function returning Eq DSL result" do + test "compares using DSL from function call" do + emp1 = %FnEqEmployee{name: "Alice", age: 30, department: "Engineering"} + emp2 = %FnEqEmployee{name: "Alice", age: 30, department: "Marketing"} + emp3 = %FnEqEmployee{name: "Bob", age: 30, department: "Engineering"} + + # Same name and age, different department - equal (DSL only checks name and age) + assert Eq.eq?(emp1, emp2) + + # Different name - not equal + refute Eq.eq?(emp1, emp3) + end + + test "not_eq? works with function-returned DSL" do + emp1 = %FnEqEmployee{name: "Alice", age: 30, department: "Eng"} + emp2 = %FnEqEmployee{name: "Alice", age: 25, department: "Eng"} + + # Different age - not equal + assert Eq.not_eq?(emp1, emp2) + end + + test "reflexivity with function-returned DSL" do + emp = %FnEqEmployee{name: "Test", age: 42, department: "QA"} + + assert Eq.eq?(emp, emp) + refute Eq.not_eq?(emp, emp) + end + end + # ============================================================================ # Ordering Tests (ord_for/2) - Basic Operations # ============================================================================ From 5e5e8ec8681c1d9b28ed801d4b7ea9aa033a9171 Mon Sep 17 00:00:00 2001 From: Joseph Koski Date: Fri, 23 Jan 2026 15:29:10 -0800 Subject: [PATCH 2/3] update --- lib/macros.ex | 187 +++++++++++++++++----------- test/macros_test.exs | 281 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 395 insertions(+), 73 deletions(-) diff --git a/lib/macros.ex b/lib/macros.ex index 1c4aa1da..2ca8a47e 100644 --- a/lib/macros.ex +++ b/lib/macros.ex @@ -270,29 +270,17 @@ defmodule Funx.Macros do defmacro eq_for(for_struct, projection, opts \\ []) do or_else = Keyword.get(opts, :or_else) custom_eq = Keyword.get(opts, :eq) - projection_ast = normalize_projection(projection, or_else) + {projection_ast, projection_type} = normalize_projection(projection, or_else) eq_module_ast = custom_eq || quote(do: Funx.Eq.Protocol) + eq_map_ast = build_eq_map_ast(projection_ast, eq_module_ast, projection_type) + quote do alias Funx.Eq alias Funx.Optics.Prism defimpl Funx.Eq.Protocol, for: unquote(for_struct) do - # Private function to build the eq_map once at module compile time - defp __eq_map__ do - __resolve_eq_map__(unquote(projection_ast), unquote(eq_module_ast)) - end - - # Eq map (plain map or Monoid.Eq struct) - use directly - defp __resolve_eq_map__(%{eq?: eq_fun, not_eq?: not_eq_fun} = map, _eq) - when is_function(eq_fun, 2) and is_function(not_eq_fun, 2) do - map - end - - # Projection - wrap in contramap - defp __resolve_eq_map__(projection, eq) do - Funx.Eq.contramap(projection, eq) - end + defp __eq_map__, do: unquote(eq_map_ast) def eq?(a, b) when is_struct(a, unquote(for_struct)) and is_struct(b, unquote(for_struct)) do @@ -373,33 +361,17 @@ defmodule Funx.Macros do defmacro ord_for(for_struct, projection, opts \\ []) do or_else = Keyword.get(opts, :or_else) custom_ord = Keyword.get(opts, :ord) - projection_ast = normalize_projection(projection, or_else) + {projection_ast, projection_type} = normalize_projection(projection, or_else) ord_module_ast = custom_ord || quote(do: Funx.Ord.Protocol) + ord_map_ast = build_ord_map_ast(projection_ast, ord_module_ast, projection_type) + quote do alias Funx.Optics.Prism alias Funx.Ord defimpl Funx.Ord.Protocol, for: unquote(for_struct) do - # Private function to build the ord_map once at module compile time - defp __ord_map__ do - __resolve_ord_map__(unquote(projection_ast), unquote(ord_module_ast)) - end - - # Ord map (plain map or Monoid.Ord struct) - use directly - defp __resolve_ord_map__( - %{lt?: lt_fun, le?: le_fun, gt?: gt_fun, ge?: ge_fun} = map, - _ord - ) - when is_function(lt_fun, 2) and is_function(le_fun, 2) and - is_function(gt_fun, 2) and is_function(ge_fun, 2) do - map - end - - # Projection - wrap in contramap - defp __resolve_ord_map__(projection, ord) do - Funx.Ord.contramap(projection, ord) - end + defp __ord_map__, do: unquote(ord_map_ast) def lt?(a, b) when is_struct(a, unquote(for_struct)) and is_struct(b, unquote(for_struct)) do @@ -436,22 +408,80 @@ defmodule Funx.Macros do end end + # ============================================================================ + # AST BUILDERS (PRIVATE) + # ============================================================================ + + # For known projections, directly call contramap + defp build_eq_map_ast(projection_ast, eq_module_ast, :projection) do + quote do + Funx.Eq.contramap(unquote(projection_ast), unquote(eq_module_ast)) + end + end + + # For function calls that might return an eq_map, do runtime check + defp build_eq_map_ast(projection_ast, eq_module_ast, :maybe_map) do + quote do + projection = unquote(projection_ast) + + case projection do + %{eq?: eq_fun, not_eq?: not_eq_fun} + when is_function(eq_fun, 2) and is_function(not_eq_fun, 2) -> + projection + + _ -> + Funx.Eq.contramap(projection, unquote(eq_module_ast)) + end + end + end + + # For known projections, directly call contramap + defp build_ord_map_ast(projection_ast, ord_module_ast, :projection) do + quote do + Funx.Ord.contramap(unquote(projection_ast), unquote(ord_module_ast)) + end + end + + # For function calls that might return an ord_map, do runtime check + defp build_ord_map_ast(projection_ast, ord_module_ast, :maybe_map) do + quote do + projection = unquote(projection_ast) + + case projection do + %{lt?: lt_fun, le?: le_fun, gt?: gt_fun, ge?: ge_fun} + when is_function(lt_fun, 2) and is_function(le_fun, 2) and + is_function(gt_fun, 2) and is_function(ge_fun, 2) -> + projection + + _ -> + Funx.Ord.contramap(projection, unquote(ord_module_ast)) + end + end + end + # ============================================================================ # PROJECTION NORMALIZATION (PRIVATE) # ============================================================================ + # Returns {normalized_ast, type} where type is :projection or :maybe_map # Atom without or_else - convert to Prism.key (safe for nil values, Nothing < Just semantics) defp normalize_projection(atom, nil) when is_atom(atom) do - quote do - Prism.key(unquote(atom)) - end + ast = + quote do + Prism.key(unquote(atom)) + end + + {ast, :projection} end # Atom with or_else - convert to {Prism.key, default} defp normalize_projection(atom, or_else) when is_atom(atom) and not is_nil(or_else) do - quote do - {Prism.key(unquote(atom)), unquote(or_else)} - end + ast = + quote do + {Prism.key(unquote(atom)), unquote(or_else)} + end + + {ast, :projection} end # Lens.key(...) - cannot use or_else with Lens @@ -460,7 +490,7 @@ defmodule Funx.Macros do or_else ) do if is_nil(or_else) do - lens_ast + {lens_ast, :projection} else raise ArgumentError, Errors.or_else_with_lens() end @@ -472,7 +502,7 @@ defmodule Funx.Macros do or_else ) do if is_nil(or_else) do - lens_ast + {lens_ast, :projection} else raise ArgumentError, Errors.or_else_with_lens() end @@ -484,11 +514,14 @@ defmodule Funx.Macros do or_else ) do if is_nil(or_else) do - prism_ast + {prism_ast, :projection} else - quote do - {unquote(prism_ast), unquote(or_else)} - end + ast = + quote do + {unquote(prism_ast), unquote(or_else)} + end + + {ast, :projection} end end @@ -498,11 +531,14 @@ defmodule Funx.Macros do or_else ) do if is_nil(or_else) do - prism_ast + {prism_ast, :projection} else - quote do - {unquote(prism_ast), unquote(or_else)} - end + ast = + quote do + {unquote(prism_ast), unquote(or_else)} + end + + {ast, :projection} end end @@ -512,7 +548,7 @@ defmodule Funx.Macros do or_else ) do if is_nil(or_else) do - traversal_ast + {traversal_ast, :projection} else raise ArgumentError, Errors.or_else_with_traversal() end @@ -520,9 +556,12 @@ defmodule Funx.Macros do # {Prism, default} tuple - cannot have additional or_else (redundant) defp normalize_projection({_prism_ast, _or_else_ast} = tuple, nil) do - quote do - unquote(tuple) - end + ast = + quote do + unquote(tuple) + end + + {ast, :projection} end defp normalize_projection({_prism_ast, _or_else_ast}, _extra_or_else) do @@ -532,7 +571,7 @@ defmodule Funx.Macros do # Captured function &fun/1 - cannot use or_else defp normalize_projection({:&, _, _} = fun_ast, or_else) do if is_nil(or_else) do - fun_ast + {fun_ast, :projection} else raise ArgumentError, Errors.or_else_with_captured_function() end @@ -541,7 +580,7 @@ defmodule Funx.Macros do # Anonymous function fn ... end - cannot use or_else defp normalize_projection({:fn, _, _} = fun_ast, or_else) do if is_nil(or_else) do - fun_ast + {fun_ast, :projection} else raise ArgumentError, Errors.or_else_with_anonymous_function() end @@ -550,34 +589,40 @@ defmodule Funx.Macros do # Struct literal (e.g., %Lens{...}) - cannot use or_else with Lens struct defp normalize_projection({:%, _, _} = struct_ast, or_else) do if is_nil(or_else) do - struct_ast + {struct_ast, :projection} else raise ArgumentError, Errors.or_else_with_struct_literal() end end - # Remote function call (Module.function()) - can use or_else (runtime check) + # Remote function call (Module.function()) - might return ord/eq map defp normalize_projection({{:., _, _}, _, _} = call_ast, or_else) do if is_nil(or_else) do - call_ast + {call_ast, :maybe_map} else - # Runtime: if helper returns Lens, contramap will raise - quote do - {unquote(call_ast), unquote(or_else)} - end + # If or_else is provided, it's definitely a projection (wrapped in tuple) + ast = + quote do + {unquote(call_ast), unquote(or_else)} + end + + {ast, :projection} end end - # Local function call (function_name()) - pass through (already handled by remote call pattern or atom) + # Local function call (function_name()) - might return ord/eq map defp normalize_projection({function_name, _, args} = call_ast, or_else) when is_atom(function_name) and is_list(args) do if is_nil(or_else) do - call_ast + {call_ast, :maybe_map} else - # Runtime: if helper returns Lens, contramap will raise - quote do - {unquote(call_ast), unquote(or_else)} - end + # If or_else is provided, it's definitely a projection (wrapped in tuple) + ast = + quote do + {unquote(call_ast), unquote(or_else)} + end + + {ast, :projection} end end end diff --git a/test/macros_test.exs b/test/macros_test.exs index 4d38f0a7..9f744425 100644 --- a/test/macros_test.exs +++ b/test/macros_test.exs @@ -166,10 +166,71 @@ defmodule Funx.MacrosTest do @moduledoc false defstruct [:item, :stars, :verified] - # Prism with or_else option + # Prism.key with or_else option Funx.Macros.ord_for(Rating, Prism.key(:stars), or_else: 0) end + defmodule NestedPayment do + @moduledoc false + defstruct [:method, :amount] + end + + defmodule NestedOrder do + @moduledoc false + defstruct [:id, :payment] + + # Prism.path with or_else option + Funx.Macros.ord_for( + NestedOrder, + Prism.path([{NestedOrder, :payment}, {NestedPayment, :amount}]), + or_else: 0 + ) + end + + defmodule EqNestedOrder do + @moduledoc false + defstruct [:id, :payment] + + # Prism.path with or_else option for eq_for + Funx.Macros.eq_for( + EqNestedOrder, + Prism.path([{EqNestedOrder, :payment}, {NestedPayment, :amount}]), + or_else: 0 + ) + end + + # Helper module for local function import tests + defmodule LocalPrismHelpers do + @moduledoc false + alias Funx.Optics.Prism + + def level_prism, do: Prism.key(:level) + def rank_prism, do: Prism.key(:rank) + end + + # Test structs for local function call + or_else (via import) + defmodule LocalFnOrdWithOrElse do + @moduledoc false + defstruct [:name, :level] + + # Import to get local function call syntax + import LocalPrismHelpers, only: [level_prism: 0] + + # Local function call with or_else option + Funx.Macros.ord_for(LocalFnOrdWithOrElse, level_prism(), or_else: 0) + end + + defmodule LocalFnEqWithOrElse do + @moduledoc false + defstruct [:name, :rank] + + # Import to get local function call syntax + import LocalPrismHelpers, only: [rank_prism: 0] + + # Local function call with or_else option + Funx.Macros.eq_for(LocalFnEqWithOrElse, rank_prism(), or_else: 0) + end + # eq_for/3 test structs defmodule EqProduct do @moduledoc false @@ -561,6 +622,7 @@ defmodule Funx.MacrosTest do use Funx.Eq alias Funx.Optics.Lens + alias Funx.Optics.Prism def card_ord do ord do @@ -582,6 +644,10 @@ defmodule Funx.MacrosTest do asc :title end end + + # Helper that returns a Prism (for testing function call + or_else) + def score_prism, do: Prism.key(:score) + def rating_prism, do: Prism.key(:rating) end defmodule FnOrdCard do @@ -608,6 +674,23 @@ defmodule Funx.MacrosTest do Funx.Macros.ord_for(FnOrdTask, OrdEqHelpers.reverse_priority_ord()) end + # Test structs for function call + or_else (returns Prism, not ord/eq map) + defmodule FnOrdWithOrElse do + @moduledoc false + defstruct [:name, :score] + + # Function call returning Prism with or_else option + Funx.Macros.ord_for(FnOrdWithOrElse, OrdEqHelpers.score_prism(), or_else: 0) + end + + defmodule FnEqWithOrElse do + @moduledoc false + defstruct [:name, :rating] + + # Function call returning Prism with or_else option + Funx.Macros.eq_for(FnEqWithOrElse, OrdEqHelpers.rating_prism(), or_else: 0) + end + describe "ord_for with function returning Ord DSL result" do test "compares using DSL from function call" do card1 = %FnOrdCard{color: :red, value: 5, suit: :hearts} @@ -696,6 +779,92 @@ defmodule Funx.MacrosTest do end end + describe "ord_for with function call + or_else (returns Prism)" do + test "compares values when both present" do + s1 = %FnOrdWithOrElse{name: "A", score: 10} + s2 = %FnOrdWithOrElse{name: "B", score: 20} + + assert Protocol.lt?(s1, s2) + assert Protocol.gt?(s2, s1) + end + + test "uses or_else default when field is nil" do + s1 = %FnOrdWithOrElse{name: "A", score: nil} + s2 = %FnOrdWithOrElse{name: "B", score: 10} + + # nil becomes 0 via or_else + assert Protocol.lt?(s1, s2) + end + + test "both nil values use default and are equal" do + s1 = %FnOrdWithOrElse{name: "A", score: nil} + s2 = %FnOrdWithOrElse{name: "B", score: nil} + + # Both nil → 0, so equal + assert Protocol.le?(s1, s2) + assert Protocol.ge?(s1, s2) + refute Protocol.lt?(s1, s2) + end + + test "nil equals explicit default value" do + s1 = %FnOrdWithOrElse{name: "A", score: nil} + s2 = %FnOrdWithOrElse{name: "B", score: 0} + + # nil → 0, so equal to explicit 0 + assert Protocol.le?(s1, s2) + assert Protocol.ge?(s1, s2) + end + + test "sorts list with mixed nil and values" do + items = [ + %FnOrdWithOrElse{name: "C", score: 50}, + %FnOrdWithOrElse{name: "A", score: nil}, + %FnOrdWithOrElse{name: "B", score: 25} + ] + + sorted = Enum.sort(items, &Protocol.le?/2) + + # nil → 0: [0, 25, 50] + assert Enum.map(sorted, & &1.score) == [nil, 25, 50] + end + end + + describe "eq_for with function call + or_else (returns Prism)" do + test "compares values when both present" do + r1 = %FnEqWithOrElse{name: "A", rating: 5} + r2 = %FnEqWithOrElse{name: "B", rating: 5} + r3 = %FnEqWithOrElse{name: "C", rating: 3} + + assert Eq.eq?(r1, r2) + refute Eq.eq?(r1, r3) + end + + test "uses or_else default when field is nil" do + r1 = %FnEqWithOrElse{name: "A", rating: nil} + r2 = %FnEqWithOrElse{name: "B", rating: 0} + + # nil becomes 0 via or_else + assert Eq.eq?(r1, r2) + end + + test "both nil values are equal via default" do + r1 = %FnEqWithOrElse{name: "A", rating: nil} + r2 = %FnEqWithOrElse{name: "B", rating: nil} + + # Both nil → 0, so equal + assert Eq.eq?(r1, r2) + end + + test "nil not equal to non-zero value" do + r1 = %FnEqWithOrElse{name: "A", rating: nil} + r2 = %FnEqWithOrElse{name: "B", rating: 5} + + # nil → 0, 0 != 5 + refute Eq.eq?(r1, r2) + assert Eq.not_eq?(r1, r2) + end + end + # ============================================================================ # Ordering Tests (ord_for/2) - Basic Operations # ============================================================================ @@ -1090,7 +1259,7 @@ defmodule Funx.MacrosTest do assert Protocol.lt?(s1, s2) end - test "Prism with or_else treats Nothing as default" do + test "Prism.key with or_else treats Nothing as default" do r1 = %Rating{item: "A", stars: nil} r2 = %Rating{item: "B", stars: 3} r3 = %Rating{item: "C", stars: 0} @@ -1100,6 +1269,25 @@ defmodule Funx.MacrosTest do assert Protocol.lt?(r1, r2) end + test "Prism.path with or_else treats nested Nothing as default" do + o1 = %NestedOrder{id: 1, payment: nil} + o2 = %NestedOrder{id: 2, payment: %NestedPayment{amount: 100}} + o3 = %NestedOrder{id: 3, payment: %NestedPayment{amount: 0}} + + # nil payment becomes amount 0 + assert Protocol.le?(o1, o3) + assert Protocol.ge?(o1, o3) + assert Protocol.lt?(o1, o2) + end + + test "Prism.path with or_else handles nil leaf value" do + o1 = %NestedOrder{id: 1, payment: %NestedPayment{amount: nil}} + o2 = %NestedOrder{id: 2, payment: %NestedPayment{amount: 50}} + + # nil amount becomes 0 + assert Protocol.lt?(o1, o2) + end + test "sorts using or_else default" do scores = [ score_fixture(), @@ -1114,6 +1302,95 @@ defmodule Funx.MacrosTest do end end + describe "eq_for/3 with Prism.path and or_else option" do + test "compares nested values when both present" do + o1 = %EqNestedOrder{id: 1, payment: %NestedPayment{amount: 100}} + o2 = %EqNestedOrder{id: 2, payment: %NestedPayment{amount: 100}} + o3 = %EqNestedOrder{id: 3, payment: %NestedPayment{amount: 50}} + + assert Eq.eq?(o1, o2) + refute Eq.eq?(o1, o3) + end + + test "uses or_else default when payment is nil" do + o1 = %EqNestedOrder{id: 1, payment: nil} + o2 = %EqNestedOrder{id: 2, payment: %NestedPayment{amount: 0}} + + # nil payment → amount 0 + assert Eq.eq?(o1, o2) + end + + test "uses or_else default when amount is nil" do + o1 = %EqNestedOrder{id: 1, payment: %NestedPayment{amount: nil}} + o2 = %EqNestedOrder{id: 2, payment: %NestedPayment{amount: 0}} + + # nil amount → 0 + assert Eq.eq?(o1, o2) + end + + test "both nil values equal via default" do + o1 = %EqNestedOrder{id: 1, payment: nil} + o2 = %EqNestedOrder{id: 2, payment: %NestedPayment{amount: nil}} + + # Both → 0 + assert Eq.eq?(o1, o2) + end + end + + describe "ord_for/3 with local function call + or_else" do + test "compares values when both present" do + l1 = %LocalFnOrdWithOrElse{name: "A", level: 5} + l2 = %LocalFnOrdWithOrElse{name: "B", level: 10} + + assert Protocol.lt?(l1, l2) + assert Protocol.gt?(l2, l1) + end + + test "uses or_else default when field is nil" do + l1 = %LocalFnOrdWithOrElse{name: "A", level: nil} + l2 = %LocalFnOrdWithOrElse{name: "B", level: 5} + + # nil becomes 0 via or_else + assert Protocol.lt?(l1, l2) + end + + test "both nil values use default and are equal" do + l1 = %LocalFnOrdWithOrElse{name: "A", level: nil} + l2 = %LocalFnOrdWithOrElse{name: "B", level: nil} + + # Both nil → 0 + assert Protocol.le?(l1, l2) + assert Protocol.ge?(l1, l2) + end + end + + describe "eq_for/3 with local function call + or_else" do + test "compares values when both present" do + r1 = %LocalFnEqWithOrElse{name: "A", rank: 3} + r2 = %LocalFnEqWithOrElse{name: "B", rank: 3} + r3 = %LocalFnEqWithOrElse{name: "C", rank: 5} + + assert Eq.eq?(r1, r2) + refute Eq.eq?(r1, r3) + end + + test "uses or_else default when field is nil" do + r1 = %LocalFnEqWithOrElse{name: "A", rank: nil} + r2 = %LocalFnEqWithOrElse{name: "B", rank: 0} + + # nil becomes 0 via or_else + assert Eq.eq?(r1, r2) + end + + test "both nil values equal via default" do + r1 = %LocalFnEqWithOrElse{name: "A", rank: nil} + r2 = %LocalFnEqWithOrElse{name: "B", rank: nil} + + # Both nil → 0 + assert Eq.eq?(r1, r2) + end + end + # ============================================================================ # Ordering Validation Tests # ============================================================================ From 8fef0798c424c707493a31965fda2a00593dcc43 Mon Sep 17 00:00:00 2001 From: Joseph Koski Date: Fri, 23 Jan 2026 16:56:21 -0800 Subject: [PATCH 3/3] Add cross-type comparison. --- lib/macros.ex | 4 ++++ test/macros_test.exs | 23 +++++++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/lib/macros.ex b/lib/macros.ex index 2ca8a47e..27cc2a55 100644 --- a/lib/macros.ex +++ b/lib/macros.ex @@ -287,10 +287,14 @@ defmodule Funx.Macros do __eq_map__().eq?.(a, b) end + def eq?(%unquote(for_struct){}, b) when is_struct(b), do: false + def not_eq?(a, b) when is_struct(a, unquote(for_struct)) and is_struct(b, unquote(for_struct)) do __eq_map__().not_eq?.(a, b) end + + def not_eq?(%unquote(for_struct){}, b) when is_struct(b), do: true end end end diff --git a/test/macros_test.exs b/test/macros_test.exs index 9f744425..557a625f 100644 --- a/test/macros_test.exs +++ b/test/macros_test.exs @@ -361,6 +361,29 @@ defmodule Funx.MacrosTest do end end + describe "eq_for - cross-type comparison" do + test "different struct types are not equal" do + product = %EqProduct{name: "Widget", rating: 5} + item = %EqItem{name: "Widget", score: 5} + + refute Eq.eq?(product, item) + assert Eq.not_eq?(product, item) + end + + test "works in mixed list with Funx.List.uniq" do + p1 = %EqProduct{name: "A", rating: 5} + p2 = %EqProduct{name: "B", rating: 5} + i1 = %EqItem{name: "C", score: 5} + + # p1 and p2 are equal by rating, i1 is different type + result = Funx.List.uniq([p1, p2, i1]) + + assert length(result) == 2 + assert p1 in result + assert i1 in result + end + end + # ============================================================================ # Equality Tests (eq_for/3) # ============================================================================