From 4a408685b8635beaa330b02f36aa07aa36062694 Mon Sep 17 00:00:00 2001 From: Joseph Koski Date: Sat, 24 Jan 2026 14:27:15 -0800 Subject: [PATCH 1/3] add bare to eq --- lib/eq.ex | 34 ++++++- lib/eq/dsl/behaviour.ex | 28 ++++- lib/eq/dsl/errors.ex | 46 +++++---- lib/eq/dsl/executor.ex | 34 +++++++ lib/eq/dsl/parser.ex | 52 +++++++++- lib/eq/dsl/step.ex | 36 ++++++- test/eq/dsl/eq_dsl_test.exs | 197 +++++++++++++++++++++++++++++++++--- 7 files changed, 384 insertions(+), 43 deletions(-) diff --git a/lib/eq.ex b/lib/eq.ex index 2031c928..2b4a2581 100644 --- a/lib/eq.ex +++ b/lib/eq.ex @@ -77,7 +77,16 @@ defmodule Funx.Eq do - `any` - At least one nested check must pass (OR logic) - `all` - All nested checks must pass (AND logic, implicit at top level) - ## Projection Types + ## Bare Eq Maps + + Eq maps can be composed directly without the `on` directive: + + - Variable - Eq map stored in a variable + - Helper call - 0-arity function returning an Eq map (e.g., `EqHelpers.by_name()`) + - Behaviour module - Module implementing `Funx.Eq.Dsl.Behaviour` + - Behaviour with options - Tuple syntax `{Module, opts}` + + ## Projection Types (with `on` directive) The DSL supports the same projection forms as Ord DSL: @@ -148,6 +157,29 @@ defmodule Funx.Eq do on [:user, :profile, :name] on [:user, :profile, :age] end + + Bare eq maps (without `on` directive): + + # Using a helper function + eq_helper = eq do + EqHelpers.name_case_insensitive() + end + + # Using a behaviour module + eq_behaviour = eq do + UserById + end + + # Behaviour with options + eq_opts = eq do + {UserByName, case_sensitive: false} + end + + # Mixing bare eq with projections + eq_mixed = eq do + UserById + on :department + end """ defmacro eq(do: block) do compile_eq(block, __CALLER__) diff --git a/lib/eq/dsl/behaviour.ex b/lib/eq/dsl/behaviour.ex index 52af10b2..cd5006f9 100644 --- a/lib/eq/dsl/behaviour.ex +++ b/lib/eq/dsl/behaviour.ex @@ -3,7 +3,7 @@ defmodule Funx.Eq.Dsl.Behaviour do Behaviour for custom equality logic in the Eq DSL. Implement this behaviour to define reusable Eq comparators that can be - used with `on` directives in the DSL without implementing the Eq protocol. + used in the DSL without implementing the Eq protocol. This is useful for teams that want to avoid teaching developers about protocols, or want struct-specific equality without global protocol implementations. @@ -19,11 +19,16 @@ defmodule Funx.Eq.Dsl.Behaviour do end end - # In DSL + # In DSL - bare usage (preferred) use Funx.Eq eq do - on UserById # Compares by id + UserById # Compares by id + end + + # Or with `on` directive + eq do + on UserById end ## With Options @@ -43,11 +48,26 @@ defmodule Funx.Eq.Dsl.Behaviour do end end - # In DSL + # In DSL - bare with options + eq do + {UserByName, case_sensitive: false} + end + + # Or with `on` directive eq do on UserByName, case_sensitive: false end + ## Composing Multiple Behaviours + + Behaviour modules can be composed with other eq expressions: + + eq do + UserById # bare behaviour + {UserByName, case_sensitive: false} # bare with options + on :email # projection + end + ## Why Use This Instead of Protocols? - **Simpler**: Just one function returning an Eq map diff --git a/lib/eq/dsl/errors.ex b/lib/eq/dsl/errors.ex index 4b32da61..f571a015 100644 --- a/lib/eq/dsl/errors.ex +++ b/lib/eq/dsl/errors.ex @@ -14,27 +14,6 @@ defmodule Funx.Eq.Dsl.Errors do # - Actionable guidance (what to do instead) # - Examples when helpful - @doc """ - Error: DSL syntax must be `on projection`, `not_on projection`, or block syntax. - """ - def invalid_dsl_syntax(got) do - """ - Invalid Eq DSL syntax. - - Expected: `on projection`, `not_on projection`, or nested blocks - Got: #{inspect(got)} - - Valid examples: - on :name - not_on :id - on Lens.key(:score) - any do - on :email - on :username - end - """ - end - @doc """ Error: Captured function with `or_else:` option. """ @@ -169,4 +148,29 @@ defmodule Funx.Eq.Dsl.Errors do Got: #{inspect(got)} """ end + + @doc """ + Error: Bare module reference without eq/1 behaviour. + """ + def bare_module_without_behaviour(module) do + """ + Bare module reference #{inspect(module)} does not implement Eq.Dsl.Behaviour. + + Module atoms are not Eq maps and will cause a runtime error. + + To fix, choose one of: + 1. Implement the Eq.Dsl.Behaviour: + @behaviour Funx.Eq.Dsl.Behaviour + def eq(_opts), do: Funx.Eq.contramap(& &1.id) + + 2. Use tuple syntax to pass options: + {#{inspect(module)}, []} + + 3. Call a function explicitly: + #{inspect(module)}.my_eq_function() + + 4. Use a variable or captured function instead: + my_eq # where my_eq is bound to an Eq map + """ + end end diff --git a/lib/eq/dsl/executor.ex b/lib/eq/dsl/executor.ex index e2878b06..ee30cd77 100644 --- a/lib/eq/dsl/executor.ex +++ b/lib/eq/dsl/executor.ex @@ -76,6 +76,16 @@ defmodule Funx.Eq.Dsl.Executor do # # Each type generates specific code based on compile-time type information. + # Bare eq map - pass through directly (non-negated) + defp node_to_ast(%Step{projection: eq_ast, negate: false, type: :bare}) do + eq_ast + end + + # Behaviour step - return the eq map from Module.eq(opts) (non-negated) + defp node_to_ast(%Step{projection: behaviour_ast, negate: false, type: :behaviour}) do + behaviour_ast + end + # Projection type - use contramap (non-negated) defp node_to_ast(%Step{projection: projection_ast, eq: eq_ast, negate: false, type: :projection}) do quote do @@ -121,6 +131,30 @@ defmodule Funx.Eq.Dsl.Executor do # # Same as non-negated but swaps eq?/not_eq? functions. + # Bare eq map - negate it (negated) + defp node_to_ast(%Step{projection: eq_ast, negate: true, type: :bare}) do + quote do + eq_map = unquote(eq_ast) + + %{ + eq?: eq_map.not_eq?, + not_eq?: eq_map.eq? + } + end + end + + # Behaviour step - negate the eq map (negated) + defp node_to_ast(%Step{projection: behaviour_ast, negate: true, type: :behaviour}) do + quote do + eq_map = unquote(behaviour_ast) + + %{ + eq?: eq_map.not_eq?, + not_eq?: eq_map.eq? + } + end + end + # Projection type - use contramap with negated eq (negated) defp node_to_ast(%Step{projection: projection_ast, eq: eq_ast, negate: true, type: :projection}) do negated_eq_ast = build_negated_eq_ast(eq_ast) diff --git a/lib/eq/dsl/parser.ex b/lib/eq/dsl/parser.ex index 533ffc48..a3ea55f7 100644 --- a/lib/eq/dsl/parser.ex +++ b/lib/eq/dsl/parser.ex @@ -80,8 +80,56 @@ defmodule Funx.Eq.Dsl.Parser do parse_projection(projection_value, [], negate, meta, caller_env) end - defp parse_entry_to_node(other, _caller_env) do - raise CompileError, description: Errors.invalid_dsl_syntax(other) + # Bare behaviour module with options: "{UserByName, case_sensitive: false}" + defp parse_entry_to_node({{:__aliases__, meta, _} = module_alias, opts}, caller_env) + when is_list(opts) do + parse_bare_behaviour_module(module_alias, opts, meta, caller_env) + end + + # Catch-all for bare eq expressions (variables, helpers, behaviour modules, etc.) + # This handles: + # - Behaviour modules: UserById → check if module has eq/1 + # - Other eq expressions: variables, function calls, etc. + defp parse_entry_to_node(eq_ast, caller_env) do + case eq_ast do + {:__aliases__, meta, _} = module_alias -> + # Try to parse as behaviour module + expanded_module = Macro.expand(module_alias, caller_env) + + if function_exported?(expanded_module, :eq, 1) do + parse_bare_behaviour_module(module_alias, [], meta, caller_env) + else + # Error: bare module reference without eq/1 will cause runtime error + raise CompileError, + line: Keyword.get(meta, :line), + description: Errors.bare_module_without_behaviour(expanded_module) + end + + _ -> + # Not a module alias, treat as bare Eq map expression + Step.new_bare(eq_ast, false, %{}) + end + end + + # Parses a bare behaviour module reference into a Step node. + defp parse_bare_behaviour_module(module_alias, opts, meta, caller_env) do + expanded_module = Macro.expand(module_alias, caller_env) + + unless function_exported?(expanded_module, :eq, 1) do + raise CompileError, + line: Keyword.get(meta, :line), + description: + "Module #{inspect(expanded_module)} does not implement the Eq.Dsl.Behaviour (missing eq/1)" + end + + # Generate AST to call Module.eq(opts) at runtime + behaviour_ast = + quote do + unquote(module_alias).eq(unquote(opts)) + end + + metadata = extract_meta(meta) + Step.new_behaviour(behaviour_ast, false, metadata) end # Parses a single projection (on/diff_on directive) into a Step node. diff --git a/lib/eq/dsl/step.ex b/lib/eq/dsl/step.ex index b6565dee..1dd96eb4 100644 --- a/lib/eq/dsl/step.ex +++ b/lib/eq/dsl/step.ex @@ -30,7 +30,7 @@ defmodule Funx.Eq.Dsl.Step do @type projection :: Macro.t() @type eq :: module() | Macro.t() - @type projection_type :: :projection | :module_eq | :eq_map | :dynamic + @type projection_type :: :bare | :behaviour | :projection | :module_eq | :eq_map | :dynamic @type t :: %__MODULE__{ projection: projection(), @@ -58,4 +58,38 @@ defmodule Funx.Eq.Dsl.Step do __meta__: meta } end + + @doc """ + Creates a new bare Eq step that passes through an Eq map directly. + + Used when the DSL contains a bare Eq map expression (variable, helper call, etc.) + without any projection wrapping. + """ + @spec new_bare(Macro.t(), boolean(), map()) :: t() + def new_bare(eq_ast, negate \\ false, meta \\ %{}) do + %__MODULE__{ + projection: eq_ast, + eq: nil, + negate: negate, + type: :bare, + __meta__: meta + } + end + + @doc """ + Creates a new step for a behaviour module. + + Used when a behaviour module is referenced in the DSL. + The `eq` field contains the AST for calling `Module.eq(opts)`. + """ + @spec new_behaviour(Macro.t(), boolean(), map()) :: t() + def new_behaviour(behaviour_ast, negate \\ false, meta \\ %{}) do + %__MODULE__{ + projection: behaviour_ast, + eq: nil, + negate: negate, + type: :behaviour, + __meta__: meta + } + end end diff --git a/test/eq/dsl/eq_dsl_test.exs b/test/eq/dsl/eq_dsl_test.exs index 325ddefa..41eb673c 100644 --- a/test/eq/dsl/eq_dsl_test.exs +++ b/test/eq/dsl/eq_dsl_test.exs @@ -849,6 +849,189 @@ defmodule Funx.Eq.DslTest do end end + # ============================================================================ + # Bare Eq Maps Tests + # ============================================================================ + + describe "bare eq maps" do + test "single eq map variable" do + name_eq = Funx.Eq.contramap(& &1.name) + + eq_bare = + eq do + name_eq + end + + assert Funx.Eq.eq?(%Person{name: "Alice"}, %Person{name: "Alice"}, eq_bare) + refute Funx.Eq.eq?(%Person{name: "Alice"}, %Person{name: "Bob"}, eq_bare) + end + + test "single helper function call" do + eq_helper = + eq do + EqHelpers.name_case_insensitive() + end + + assert Funx.Eq.eq?(%Person{name: "Alice"}, %Person{name: "alice"}, eq_helper) + refute Funx.Eq.eq?(%Person{name: "Alice"}, %Person{name: "Bob"}, eq_helper) + end + + test "multiple bare eq maps" do + eq_multi = + eq do + EqHelpers.name_case_insensitive() + EqHelpers.age_mod_10() + end + + assert Funx.Eq.eq?( + %Person{name: "Alice", age: 25}, + %Person{name: "ALICE", age: 35}, + eq_multi + ) + + refute Funx.Eq.eq?( + %Person{name: "Alice", age: 25}, + %Person{name: "ALICE", age: 36}, + eq_multi + ) + end + + test "bare eq with diff_on projection (mixed)" do + eq_mixed = + eq do + EqHelpers.name_case_insensitive() + diff_on(:id) + end + + # Same name (case insensitive) AND different id + assert Funx.Eq.eq?( + %Person{name: "Alice", id: 1}, + %Person{name: "ALICE", id: 2}, + eq_mixed + ) + + # Same name but same id -> should fail + refute Funx.Eq.eq?( + %Person{name: "Alice", id: 1}, + %Person{name: "ALICE", id: 1}, + eq_mixed + ) + end + + test "bare eq inside any block" do + eq_any = + eq do + any do + EqHelpers.name_case_insensitive() + EqHelpers.age_mod_10() + end + end + + # Same name (case insensitive) OR same age mod 10 + assert Funx.Eq.eq?( + %Person{name: "Alice", age: 25}, + %Person{name: "ALICE", age: 36}, + eq_any + ) + + assert Funx.Eq.eq?( + %Person{name: "Alice", age: 25}, + %Person{name: "Bob", age: 35}, + eq_any + ) + + refute Funx.Eq.eq?( + %Person{name: "Alice", age: 25}, + %Person{name: "Bob", age: 36}, + eq_any + ) + end + + test "bare eq inside all block" do + eq_all = + eq do + all do + EqHelpers.name_case_insensitive() + EqHelpers.age_mod_10() + end + end + + # Same name (case insensitive) AND same age mod 10 + assert Funx.Eq.eq?( + %Person{name: "Alice", age: 25}, + %Person{name: "ALICE", age: 35}, + eq_all + ) + + refute Funx.Eq.eq?( + %Person{name: "Alice", age: 25}, + %Person{name: "ALICE", age: 36}, + eq_all + ) + end + + test "bare behaviour module" do + eq_behaviour = + eq do + UserById + end + + assert Funx.Eq.eq?(%Person{id: 1, name: "Alice"}, %Person{id: 1, name: "Bob"}, eq_behaviour) + + refute Funx.Eq.eq?( + %Person{id: 1, name: "Alice"}, + %Person{id: 2, name: "Alice"}, + eq_behaviour + ) + end + + test "bare behaviour module with options" do + eq_behaviour = + eq do + {UserByName, case_sensitive: false} + end + + assert Funx.Eq.eq?(%Person{name: "Alice"}, %Person{name: "ALICE"}, eq_behaviour) + refute Funx.Eq.eq?(%Person{name: "Alice"}, %Person{name: "Bob"}, eq_behaviour) + end + + test "compile error for bare module without behaviour" do + defmodule NotAnEqBehaviour do + def some_function, do: :ok + end + + assert_raise CompileError, ~r/does not implement Eq.Dsl.Behaviour/, fn -> + Code.eval_quoted( + quote do + use Funx.Eq + + eq do + NotAnEqBehaviour + end + end + ) + end + end + + test "compile error for bare module with options without behaviour" do + defmodule NotAnEqBehaviourWithOpts do + def some_function, do: :ok + end + + assert_raise CompileError, ~r/does not implement the Eq.Dsl.Behaviour/, fn -> + Code.eval_quoted( + quote do + use Funx.Eq + + eq do + {NotAnEqBehaviourWithOpts, some_opt: true} + end + end + ) + end + end + end + # ============================================================================ # Protocol Dispatch Tests # ============================================================================ @@ -912,20 +1095,6 @@ defmodule Funx.Eq.DslTest do # ============================================================================ describe "compile-time errors" do - test "rejects invalid syntax" do - assert_raise CompileError, fn -> - Code.compile_quoted( - quote do - use Funx.Eq - - eq do - check(:name) - end - end - ) - end - end - test "rejects or_else with captured function" do assert_raise CompileError, fn -> Code.compile_quoted( From 4bca491db8aa449cc666aa2ff348f517a48b13a5 Mon Sep 17 00:00:00 2001 From: Joseph Koski Date: Sun, 25 Jan 2026 10:23:20 -0800 Subject: [PATCH 2/3] Update error --- lib/eq/dsl/executor.ex | 27 ++++++--------------------- lib/eq/dsl/parser.ex | 3 +-- test/eq/dsl/eq_dsl_test.exs | 4 ++-- 3 files changed, 9 insertions(+), 25 deletions(-) diff --git a/lib/eq/dsl/executor.ex b/lib/eq/dsl/executor.ex index ee30cd77..31622999 100644 --- a/lib/eq/dsl/executor.ex +++ b/lib/eq/dsl/executor.ex @@ -76,16 +76,12 @@ defmodule Funx.Eq.Dsl.Executor do # # Each type generates specific code based on compile-time type information. - # Bare eq map - pass through directly (non-negated) - defp node_to_ast(%Step{projection: eq_ast, negate: false, type: :bare}) do + # Bare eq map or behaviour - pass through directly (non-negated) + defp node_to_ast(%Step{projection: eq_ast, negate: false, type: type}) + when type in [:bare, :behaviour] do eq_ast end - # Behaviour step - return the eq map from Module.eq(opts) (non-negated) - defp node_to_ast(%Step{projection: behaviour_ast, negate: false, type: :behaviour}) do - behaviour_ast - end - # Projection type - use contramap (non-negated) defp node_to_ast(%Step{projection: projection_ast, eq: eq_ast, negate: false, type: :projection}) do quote do @@ -131,8 +127,9 @@ defmodule Funx.Eq.Dsl.Executor do # # Same as non-negated but swaps eq?/not_eq? functions. - # Bare eq map - negate it (negated) - defp node_to_ast(%Step{projection: eq_ast, negate: true, type: :bare}) do + # Bare eq map or behaviour - negate it (negated) + defp node_to_ast(%Step{projection: eq_ast, negate: true, type: type}) + when type in [:bare, :behaviour] do quote do eq_map = unquote(eq_ast) @@ -143,18 +140,6 @@ defmodule Funx.Eq.Dsl.Executor do end end - # Behaviour step - negate the eq map (negated) - defp node_to_ast(%Step{projection: behaviour_ast, negate: true, type: :behaviour}) do - quote do - eq_map = unquote(behaviour_ast) - - %{ - eq?: eq_map.not_eq?, - not_eq?: eq_map.eq? - } - end - end - # Projection type - use contramap with negated eq (negated) defp node_to_ast(%Step{projection: projection_ast, eq: eq_ast, negate: true, type: :projection}) do negated_eq_ast = build_negated_eq_ast(eq_ast) diff --git a/lib/eq/dsl/parser.ex b/lib/eq/dsl/parser.ex index a3ea55f7..85fcc152 100644 --- a/lib/eq/dsl/parser.ex +++ b/lib/eq/dsl/parser.ex @@ -118,8 +118,7 @@ defmodule Funx.Eq.Dsl.Parser do unless function_exported?(expanded_module, :eq, 1) do raise CompileError, line: Keyword.get(meta, :line), - description: - "Module #{inspect(expanded_module)} does not implement the Eq.Dsl.Behaviour (missing eq/1)" + description: Errors.bare_module_without_behaviour(expanded_module) end # Generate AST to call Module.eq(opts) at runtime diff --git a/test/eq/dsl/eq_dsl_test.exs b/test/eq/dsl/eq_dsl_test.exs index 41eb673c..0e17bc3f 100644 --- a/test/eq/dsl/eq_dsl_test.exs +++ b/test/eq/dsl/eq_dsl_test.exs @@ -1000,7 +1000,7 @@ defmodule Funx.Eq.DslTest do def some_function, do: :ok end - assert_raise CompileError, ~r/does not implement Eq.Dsl.Behaviour/, fn -> + assert_raise CompileError, fn -> Code.eval_quoted( quote do use Funx.Eq @@ -1018,7 +1018,7 @@ defmodule Funx.Eq.DslTest do def some_function, do: :ok end - assert_raise CompileError, ~r/does not implement the Eq.Dsl.Behaviour/, fn -> + assert_raise CompileError, fn -> Code.eval_quoted( quote do use Funx.Eq From 8201832b1a5c98585e5540a1759167065686e41c Mon Sep 17 00:00:00 2001 From: Joseph Koski Date: Sun, 25 Jan 2026 10:30:41 -0800 Subject: [PATCH 3/3] Update docs --- guides/dsl/eq.md | 80 ++++++++++++++++++++++++++++++--- livebooks/eq/eq_dsl.livemd | 90 +++++++++++++++++++++++++++++++++++++- usage-rules/eq.md | 47 +++++++++++++++++++- 3 files changed, 208 insertions(+), 9 deletions(-) diff --git a/guides/dsl/eq.md b/guides/dsl/eq.md index b853ab06..9480942d 100644 --- a/guides/dsl/eq.md +++ b/guides/dsl/eq.md @@ -20,14 +20,19 @@ Compilation ├── Block (all - implicit at top level) │ ├── Step (on :name) │ ├── Step (on :age) + │ ├── Step (bare: my_eq_variable) │ └── Block (any) │ ├── Step (on :email) - │ └── Step (on :username) + │ └── Step (bare: EqHelpers.by_name()) ``` ## Parser -The parser converts the DSL block into a tree of Step and Block structures. It normalizes all projection syntax into one of four canonical types that `contramap/2` accepts: +The parser converts the DSL block into a tree of Step and Block structures. It handles two categories of entries: + +### Projection-based entries (with `on`/`diff_on`) + +These normalize projection syntax into one of four canonical types that `contramap/2` accepts: * `Lens.t()` - Bare lens struct * `Prism.t()` - Bare prism struct (Nothing == Nothing) @@ -47,12 +52,27 @@ All syntax sugar resolves to these types: * `Behaviour` → Behaviour.eq([]) (returns Eq map) * `StructModule` → `Utils.to_eq_map(StructModule)` (uses protocol) -Additionally, the parser tracks a `type` field for each Step to enable compile-time optimization: +### Bare Eq map entries (without directive) + +These are Eq maps passed through directly without `on`: + +* `my_eq` - Variable holding an Eq map +* `EqHelpers.by_name()` - Helper function returning an Eq map +* `UserById` - Behaviour module (must implement `eq/1`) +* `{UserByName, opts}` - Behaviour module with options + +Bare module references are validated at compile time - modules without `eq/1` raise a `CompileError`. +### Type tracking + +The parser tracks a `type` field for each Step to enable compile-time optimization: + +* `:bare` - Bare Eq map (variable, helper) → pass through directly +* `:behaviour` - Behaviour module → call `Module.eq(opts)` and use result * `:projection` - Optics or functions → wrap in contramap * `:module_eq` - Module with `eq?/2` → convert via `to_eq_map` -* `:eq_map` - Behaviour returning Eq map → use directly -* `:dynamic` - Unknown (0-arity helper) → runtime detection +* `:eq_map` - Behaviour returning Eq map (via `on`) → use directly +* `:dynamic` - Unknown (0-arity helper with `on`) → runtime detection The parser validates projections and raises compile-time errors for unsupported syntax, producing the final structure tree that the executor will compile. @@ -88,9 +108,11 @@ Each directive compiles to: The executor uses the `type` field from Steps to generate specific code paths, eliminating runtime branching and compiler warnings: +* `:bare` - Pass through Eq map directly (or negate if needed) +* `:behaviour` - Call `Module.eq(opts)` and use result directly (or negate) * `:projection` - Direct contramap with projection * `:module_eq` - Convert module via `to_eq_map` then use -* `:eq_map` - Use Eq map directly (from Behaviour) +* `:eq_map` - Use Eq map directly (from Behaviour via `on`) * `:dynamic` - Runtime case statement to detect type ### Negation (diff_on) @@ -132,6 +154,28 @@ Utils.concat_all([ ]) ``` +### Bare Eq Compilation Example + +```elixir +eq do + UserById + EqHelpers.name_case_insensitive() + on :department +end +``` + +Compiles to: + +```elixir +Utils.concat_all([ + UserById.eq([]), + EqHelpers.name_case_insensitive(), + Utils.contramap(Prism.key(:department), Funx.Eq) +]) +``` + +Bare Eq maps are passed through directly (or have their `eq?/not_eq?` swapped if negation were supported). + ### List Paths (Nested Field Access) List paths provide convenient syntax for accessing nested fields without manually composing optics: @@ -179,7 +223,7 @@ Modules participating in the Eq DSL implement `Funx.Eq.Dsl.Behaviour`. The parse The `eq/1` callback receives: -* `opts` - Keyword list of options passed in the DSL (e.g., `on MyBehaviour, threshold: 0.5`) +* `opts` - Keyword list of options passed in the DSL Example: @@ -201,7 +245,11 @@ defmodule FuzzyStringEq do # Implementation here end end +``` + +### Usage with `on` directive +```elixir eq do on FuzzyStringEq, threshold: 0.9 end @@ -209,6 +257,24 @@ end The executor uses the returned Eq map directly (type `:eq_map`), avoiding the need to wrap it in `contramap`. +### Bare usage (preferred) + +Behaviour modules can also be used without the `on` directive: + +```elixir +# Bare behaviour module +eq do + FuzzyStringEq +end + +# Bare behaviour with options (tuple syntax) +eq do + {FuzzyStringEq, threshold: 0.9} +end +``` + +The executor calls `Module.eq(opts)` and uses the returned Eq map directly (type `:behaviour`). + ## Equivalence Relations and diff_on The Eq DSL supports two modes: diff --git a/livebooks/eq/eq_dsl.livemd b/livebooks/eq/eq_dsl.livemd index f53e67ff..f86d0b1c 100644 --- a/livebooks/eq/eq_dsl.livemd +++ b/livebooks/eq/eq_dsl.livemd @@ -32,10 +32,18 @@ eq do diff_on # Field/projection must differ any do ... end # At least one nested check must pass (OR) all do ... end # All nested checks must pass (AND) + # Eq map passed through directly end ``` -### Valid Projections: +### Bare Eq Maps: + +* Variables: `my_eq` (Eq map stored in variable) +* Helper calls: `EqHelpers.by_name()` (0-arity function returning Eq map) +* Behaviour modules: `UserById` (module with `eq/1` callback) +* Behaviour with options: `{UserByName, case_sensitive: false}` + +### Valid Projections (with `on` directive): * Atoms: `:field_name`, converts to `Prism.key(:field_name)` * Lists: `[:a, :b]`, converts to `Prism.path([:a, :b])` (supports nested keys and structs) @@ -783,6 +791,85 @@ eq_name_ci = Eq.eq?(%Person{name: "Alice"}, %Person{name: "ALICE"}, eq_name_ci) ``` +## Bare Eq Maps + +Eq maps can be composed directly without the `on` directive. This is useful for composing helper functions, behaviour modules, or Eq maps stored in variables. + +### Bare Variables + +Pass an Eq map stored in a variable directly: + +```elixir +name_eq = Eq.contramap(& &1.name) + +eq_bare = + eq do + name_eq + end + +Eq.eq?(%Person{name: "Alice"}, %Person{name: "Alice"}, eq_bare) +``` + +### Bare Helper Functions + +Call helper functions that return Eq maps: + +```elixir +defmodule EqHelpers do + def name_case_insensitive do + Funx.Eq.contramap(fn p -> String.downcase(p.name) end) + end +end +``` + +```elixir +eq_helper = + eq do + EqHelpers.name_case_insensitive() + end + +Eq.eq?(%Person{name: "Alice"}, %Person{name: "ALICE"}, eq_helper) +``` + +### Bare Behaviour Modules + +Reference behaviour modules directly (without `on`): + +```elixir +eq_by_id = + eq do + UserById + end + +Eq.eq?(%Person{id: 1, name: "Alice"}, %Person{id: 1, name: "Bob"}, eq_by_id) +``` + +### Bare Behaviour with Options + +Use tuple syntax to pass options to behaviour modules: + +```elixir +eq_name_ci = + eq do + {UserByName, case_sensitive: false} + end + +Eq.eq?(%Person{name: "Alice"}, %Person{name: "ALICE"}, eq_name_ci) +``` + +### Mixing Bare Eq with Projections + +Bare Eq maps can be combined with `on` directives: + +```elixir +eq_mixed = + eq do + UserById + on :department + EqHelpers.name_case_insensitive() + end +``` + ## Protocol Dispatch LiveBook does not handle creating new protocols @@ -985,6 +1072,7 @@ The Eq DSL provides a declarative, type-safe way to build complex equality compa * Functions for custom transformations * Behaviours for reusable equality logic * Bare structs for type-based filtering +* Bare Eq maps for direct composition (variables, helpers, behaviours) * `any` blocks for OR logic * `all` blocks for explicit AND logic * `diff_on` for non-equivalence constraints (escape hatch from Eq laws) diff --git a/usage-rules/eq.md b/usage-rules/eq.md index f5534ae4..043cec45 100644 --- a/usage-rules/eq.md +++ b/usage-rules/eq.md @@ -198,8 +198,18 @@ The DSL version: - `diff_on ` - Field/projection must be different - `any do ... end` - At least one nested check must pass (OR logic) - `all do ... end` - All nested checks must pass (AND logic, implicit at top level) +- `` - Eq map passed through directly (no directive needed) -### Supported Projections +### Bare Eq Maps + +Eq maps can be composed directly without the `on` directive: + +- `my_eq` - Variable holding an Eq map +- `EqHelpers.by_name()` - Helper function returning an Eq map +- `UserById` - Behaviour module (must implement `eq/1`) +- `{UserByName, case_sensitive: false}` - Behaviour module with options + +### Supported Projections (with `on` directive) - `on :atom` - Field access via `Prism.key/1` (Nothing == Nothing) - `on [:a, :b]` - List path via `Prism.path/1` (nested keys and structs, Nothing == Nothing) @@ -313,6 +323,39 @@ eq do end ``` +**Bare Eq maps (without `on` directive):** + +```elixir +# Variable holding an Eq map +name_eq = Eq.contramap(& &1.name) + +eq do + name_eq +end + +# Helper function returning an Eq map +eq do + EqHelpers.name_case_insensitive() +end + +# Behaviour module (bare) +eq do + UserById +end + +# Behaviour with options (tuple syntax) +eq do + {UserByName, case_sensitive: false} +end + +# Mixing bare Eq with projections +eq do + UserById + on :department + EqHelpers.name_case_insensitive() +end +``` + ### Projection Type Selection Guide **Use atoms (`:field`) when:** @@ -791,6 +834,7 @@ The Eq DSL provides declarative multi-field equality: - `diff_on ` - Field must be different (breaks transitivity) - `any do ... end` - OR logic (at least one must match) - `all do ... end` - AND logic (all must match) +- `` - Eq map passed through directly **Key Patterns:** @@ -799,6 +843,7 @@ The Eq DSL provides declarative multi-field equality: - Use Prism explicitly for sum types with partial access (`Nothing == Nothing`) - Use Prism with or_else for optional fields with specific defaults - Use behaviours for complex, reusable equality logic (fuzzy matching, etc.) +- Use bare Eq maps for composing variables, helpers, or behaviour modules - Nested `any`/`all` blocks for complex boolean logic - DSL results can be used with `eq_for` macro for protocol implementation - Avoid `diff_on` if you need equivalence classes (grouping, uniq, sets)