Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 73 additions & 7 deletions guides/dsl/eq.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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.

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:

Expand All @@ -201,14 +245,36 @@ defmodule FuzzyStringEq do
# Implementation here
end
end
```

### Usage with `on` directive

```elixir
eq do
on FuzzyStringEq, threshold: 0.9
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:
Expand Down
34 changes: 33 additions & 1 deletion lib/eq.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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__)
Expand Down
28 changes: 24 additions & 4 deletions lib/eq/dsl/behaviour.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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
Expand Down
46 changes: 25 additions & 21 deletions lib/eq/dsl/errors.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""
Expand Down Expand Up @@ -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
19 changes: 19 additions & 0 deletions lib/eq/dsl/executor.ex
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,12 @@ defmodule Funx.Eq.Dsl.Executor do
#
# Each type generates specific code based on compile-time type information.

# 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

# Projection type - use contramap (non-negated)
defp node_to_ast(%Step{projection: projection_ast, eq: eq_ast, negate: false, type: :projection}) do
quote do
Expand Down Expand Up @@ -121,6 +127,19 @@ defmodule Funx.Eq.Dsl.Executor do
#
# Same as non-negated but swaps eq?/not_eq? functions.

# 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)

%{
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)
Expand Down
51 changes: 49 additions & 2 deletions lib/eq/dsl/parser.ex
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,55 @@ 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: Errors.bare_module_without_behaviour(expanded_module)
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.
Expand Down
Loading