Current section
Files
Jump to
Current section
Files
lib/ord/dsl.ex
defmodule Funx.Ord.Dsl do
@moduledoc """
The `Funx.Ord.Dsl` module provides a declarative syntax for building total orderings over complex data structures.
The DSL compiles at compile time into efficient `Ord` compositions using `contramap`, `reverse`, and `concat`. This eliminates the need to manually compose ordering functions while providing a clear, readable syntax for expressing multi-field lexicographic ordering.
Each projection extracts a comparable value from the input and specifies a direction (`:asc` or `:desc`). The DSL handles optional fields, nested structures, custom projections, and type filtering through a unified interface.
## Deterministic Ordering
The DSL always produces deterministic total orderings by automatically appending an
identity projection as the final tiebreaker. This means:
- When all projections result in equality, the ordering falls back to the value's
`Ord` protocol implementation
- Custom orderings are refinements of the domain's natural ordering
- No arbitrary tiebreaking via insertion order or Elixir term ordering
- Sorts are reproducible and deterministic across runs
For example, if `Product` has `ord_for(Product, :amount)` defining its natural ordering,
then `ord do asc :name end` will sort by name first, then by amount for ties.
This module is useful for:
- Multi-field sorting with different directions per field
- Handling optional values explicitly with `or_else` fallbacks
- Type partitioning to group heterogeneous data before ordering
- Custom ordering logic via behaviour modules or projection functions
- Nested field access through lenses and prisms
- Building deterministic orderings that respect domain semantics
### Directions
- `:asc` - Ascending order (smallest to largest)
- `:desc` - Descending order (largest to smallest)
### Projection Types
The DSL supports eight projection forms, all normalized at compile time:
- Atom - Field access via `Prism.key(atom)`. Safe for nil values with `Nothing < Just` semantics.
- Atom with or_else - Optional field via `{Prism.key(atom), or_else}`. Treats `nil` as the fallback value.
- Function - Direct projection `fn x -> ... end` or `&fun/1`. Must return a comparable value.
- Lens - Explicit lens for nested access. Total but raises on missing keys or nil intermediate values. Must be explicit: `Lens.key(:field)`.
- Prism - Explicit prism for optional fields. Returns `Maybe` with `Nothing < Just` semantics.
- Prism with or_else - Explicit prism `{Prism.t(), or_else}` for optional with fallback.
- Behaviour - Custom ordering via `c:Funx.Ord.Dsl.Behaviour.ord/1`.
- Ord variable - Existing ord map to compose or reverse. Enables reusable ordering definitions.
> Note: Atoms use Prism by default for safety. Use explicit `Lens.key(:field)` when you need
> total access that raises on missing keys.
### Utility Functions
Orderings created with `ord/1` return a `%Funx.Monoid.Ord{}` struct compatible with:
- `Funx.Ord.Utils.compare/3` - Compare two values, returns `:lt`, `:eq`, or `:gt`.
- `Funx.Ord.Utils.comparator/1` - Convert to an Elixir comparator function for `Enum.sort/2`.
- `Funx.Ord.Utils.min/3` - Returns the lesser of two values.
- `Funx.Ord.Utils.max/3` - Returns the greater of two values.
- `Funx.List.sort/2` - Sort a list using the ordering.
## Examples
Simple multi-field ordering:
iex> use Funx.Ord.Dsl
iex> defmodule Person, do: defstruct [:name, :age]
iex> ord_person = ord do
...> asc :name
...> desc :age
...> end
iex> alice = %Person{name: "Alice", age: 30}
iex> bob = %Person{name: "Bob", age: 25}
iex> Funx.Ord.Utils.compare(alice, bob, ord_person)
:lt
Optional fields with or_else:
iex> use Funx.Ord.Dsl
iex> defmodule Item, do: defstruct [:name, :score]
iex> ord_item = ord do
...> asc :score, or_else: 0
...> asc :name
...> end
iex> item1 = %Item{name: "A", score: nil}
iex> item2 = %Item{name: "B", score: 10}
iex> Funx.Ord.Utils.compare(item1, item2, ord_item)
:lt
Function projections:
iex> use Funx.Ord.Dsl
iex> ord_length = ord do
...> asc &String.length/1
...> end
iex> items = ["apple", "kiwi", "banana"]
iex> Enum.sort(items, Funx.Ord.Utils.comparator(ord_length))
["kiwi", "apple", "banana"]
Composing ord variables:
iex> use Funx.Ord.Dsl
iex> defmodule Item, do: defstruct [:name, :priority, :created_at]
iex> base_ord = ord do
...> asc :name
...> end
iex> combined_ord = ord do
...> desc :priority, or_else: 0
...> asc base_ord
...> end
iex> item1 = %Item{name: "A", priority: 1, created_at: nil}
iex> item2 = %Item{name: "B", priority: 1, created_at: nil}
iex> Funx.Ord.Utils.compare(item1, item2, combined_ord)
:lt
## Compile-Time Behavior
The DSL expands into direct `Ord` function calls at compile time with zero runtime overhead:
ord do
asc :name
desc :age
end
Compiles to:
concat([
contramap(Prism.key(:name)),
reverse(contramap(Prism.key(:age)))
])
## Protocol Dispatch
The DSL leverages the `Funx.Ord` protocol for custom types. Projected values can be any type that implements `Ord`:
defmodule Address do
defstruct [:city, :state]
end
defimpl Funx.Ord, for: Address do
def lt?(a, b), do: {a.state, a.city} < {b.state, b.city}
def le?(a, b), do: {a.state, a.city} <= {b.state, b.city}
def gt?(a, b), do: {a.state, a.city} > {b.state, b.city}
def ge?(a, b), do: {a.state, a.city} >= {b.state, b.city}
end
ord do
asc :address # Uses Funx.Ord.Address protocol implementation
end
## DSL Usage
Import the DSL with `use Funx.Ord.Dsl`:
use Funx.Ord.Dsl
ord do
desc :priority
asc :created_at
asc :name
end
This creates a total ordering that first sorts by priority (descending), then by creation time (ascending), then by name (ascending) for tie-breaking.
"""
# credo:disable-for-this-file Credo.Check.Design.AliasUsage
alias Funx.Ord.Dsl.Executor
alias Funx.Ord.Dsl.Parser
defmacro __using__(_opts) do
quote do
import Funx.Ord.Dsl
end
end
# ============================================================================
# PUBLIC MACROS (ENTRY POINT)
# ============================================================================
@doc """
Creates an ordering from a block of projection specifications.
Returns a `%Funx.Monoid.Ord{}` struct that can be used with `Funx.Ord.Utils`
functions like `compare/3`, `max/3`, `min/3`, or `comparator/1`.
## Examples
ord do
asc :name
desc :age
end
ord do
asc :score, or_else: 0
desc &String.length(&1.bio)
end
"""
defmacro ord(do: block) do
compile_ord(block, __CALLER__)
end
# ============================================================================
# ORD COMPILATION (COMPILE-TIME)
# ============================================================================
defp compile_ord(block, caller_env) do
# Parse operations into Step structs
steps = Parser.parse_operations(block, caller_env)
# Execute (compile) steps to quoted AST
Executor.execute_steps(steps)
end
end