The Eq DSL is a builder DSL that constructs equality comparators for later use. See the DSL Overview for the distinction between builder and pipeline DSLs.

Structure

An eq block compiles entirely at compile time to quoted AST that builds an %Funx.Monoid.Eq.All{} struct. Unlike pipeline DSLs (Maybe, Either), there is no runtime executor—the DSL produces static composition of contramap, concat_all, and concat_any calls that execute directly.

Internal Representation

The Eq DSL uses two structure types to represent the equality composition:

  • Step - Contains projection AST, eq module, negate flag, type, and metadata
  • Block - Contains strategy (:all or :any), children, and metadata

Each Step describes a single equality check (on a field or projection). Each Block groups multiple checks with AND/OR logic. The compiler pattern-matches on these structs to generate the final quoted AST.

Compilation
    ├── Block (all - implicit at top level)
    │   ├── Step (on :name)
    │   ├── Step (on :age)
    │   ├── Step (bare: my_eq_variable)
    │   └── Block (any)
    │       ├── Step (on :email)
    │       └── Step (bare: EqHelpers.by_name())

Parser

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)
  • {Prism.t(), or_else} - Prism with or_else value
  • (a -> b) - Projection function

All syntax sugar resolves to these types:

  • :atomPrism.key(:atom)
  • [:a, :b]Prism.path([:a, :b]) (supports nested keys and structs)
  • :atom, or_else: x{Prism.key(:atom), x}
  • [:a, :b], or_else: x{Prism.path([:a, :b]), x}
  • Lens.key(...)Lens.key(...) (pass through)
  • Prism.key(...)Prism.key(...) (pass through)
  • {Prism, x}{Prism, x} (pass through)
  • fn -> ... endfn -> ... end (pass through)
  • Behaviour → Behaviour.eq([]) (returns Eq map)
  • StructModuleUtils.to_eq_map(StructModule) (uses protocol)

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 (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.

Transformers

The Eq DSL does not currently support transformers. All compilation is handled by the parser and executor without intermediate rewriting stages.

Execution

The executor runs at compile time and generates quoted AST. It recursively walks the structure tree:

  1. Take normalized structures from the parser
  2. For each Step:
    • If negate: falseUtils.contramap(projection, eq)
    • If negate: trueUtils.contramap(projection, negated_eq)
  3. For each Block:
    • If strategy: :allUtils.concat_all([children...])
    • If strategy: :anyUtils.concat_any([children...])
  4. Top-level operations are implicitly combined with concat_all (AND logic)

Execution Model

An empty eq block compiles to an identity Eq that considers all values equal. Similarly, an empty ord block compiles to an identity Ord where all values compare as :eq.

Each directive compiles to:

  • oncontramap(projection, eq)
  • diff_oncontramap(projection, negated_eq)
  • allconcat_all([children...])
  • anyconcat_any([children...])

Type-Specific Code Generation

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 via on)
  • :dynamic - Runtime case statement to detect type

Negation (diff_on)

The diff_on directive swaps the eq?/not_eq? functions to check for inequality. This is implemented by creating a negated Eq map:

negated_eq = %{
  eq?: original.not_eq?,
  not_eq?: original.eq?
}

Important: Using diff_on breaks transitivity and creates an Extended Eq that is not an equivalence relation. Do not use with grouping operations like Funx.List.uniq/2 or MapSet.

Compilation Example

eq do
  on :name
  on :age
  any do
    on :email
    on :username
  end
end

Compiles to:

Utils.concat_all([
  Utils.contramap(Prism.key(:name), Funx.Eq),
  Utils.contramap(Prism.key(:age), Funx.Eq),
  Utils.concat_any([
    Utils.contramap(Prism.key(:email), Funx.Eq),
    Utils.contramap(Prism.key(:username), Funx.Eq)
  ])
])

Bare Eq Compilation Example

eq do
  UserById
  EqHelpers.name_case_insensitive()
  on :department
end

Compiles to:

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:

# Instead of:
eq do
  on Prism.path([:user, :profile, :name])
end

# You can write:
eq do
  on [:user, :profile, :name]
end

List paths support both atom keys and struct modules:

defmodule Company, do: defstruct [:name, :address]
defmodule Address, do: defstruct [:city, :state]

# Compare companies by nested city
eq_by_city = eq do
  on [Company, :address, Address, :city]
end

company1 = %Company{name: "ACME", address: %Address{city: "NYC", state: "NY"}}
company2 = %Company{name: "Corp", address: %Address{city: "NYC", state: "NY"}}

Funx.Eq.eq?(company1, company2, eq_by_city)  # true

List paths work with or_else for handling missing values:

eq do
  on [:user, :profile, :age], or_else: 0
end

Behaviours

Modules participating in the Eq DSL implement Funx.Eq.Dsl.Behaviour. The parser detects behaviour modules and calls their eq/1 callback, which must return an Eq map (not a projection).

The eq/1 callback receives:

  • opts - Keyword list of options passed in the DSL

Example:

defmodule FuzzyStringEq do
  @behaviour Funx.Eq.Dsl.Behaviour

  @impl true
  def eq(opts) do
    threshold = Keyword.get(opts, :threshold, 0.8)

    %{
      eq?: fn a, b -> string_similarity(a, b) >= threshold end,
      not_eq?: fn a, b -> string_similarity(a, b) < threshold end
    }
  end

  defp string_similarity(a, b) do
    # Implementation here
  end
end

Usage with on directive

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:

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

Core Eq (Equivalence Relations)

Using only on, all, and any creates a Core Eq that forms an equivalence relation:

  • Reflexive: eq?(a, a) is always true
  • Symmetric: If eq?(a, b) then eq?(b, a)
  • Transitive: If eq?(a, b) and eq?(b, c) then eq?(a, c)

Core Eq safely partitions values into equivalence classes, making it suitable for:

Extended Eq (Boolean Predicates)

Using diff_on creates an Extended Eq that expresses boolean equality predicates but does not guarantee transitivity.

Example transitivity violation:

defmodule Person, do: defstruct [:name, :id]

eq_diff_id = eq do
  on :name
  diff_on :id
end

a = %Person{name: "Alice", id: 1}
b = %Person{name: "Alice", id: 2}
c = %Person{name: "Alice", id: 1}

eq?(a, b)  # true  (same name, different ids)
eq?(b, c)  # true  (same name, different ids)
eq?(a, c)  # false (same name, SAME id - violates diff_on)

Even though a == b and b == c, we have a != c, violating transitivity.

Rule: If you need equivalence classes, do not use diff_on. Use it only for boolean predicates where transitivity is not required.