Funx.Ord.Dsl
Mix.install([
{:funx, github: "JKWA/funx", ref: "feeaaf3"}
])
Overview
The Funx.Ord.Dsl module provides a declarative syntax for building complex orderings (comparators) by composing projections into lexicographic orderings.
The DSL builds an Ord instance that consumes projections - functions that extract values for comparison. Lens and Prism optics are one family of reusable projections the DSL accepts, with specific semantics around totality and partiality. The lexicographic composition allows you to express “sort by X, then by Y, then by Z” in a clear, composable way.
Key Features:
-
Declarative
ascanddescdirectives for sorting - Support for atoms, lenses, prisms, functions, and custom behaviours
- Automatic composition into lexicographic orderings
- Total ordering defined for nil values via Prism semantics (Nothing < Just)
- Type-based partitioning with bare struct modules
Quick Reference
DSL Syntax:
ord do
asc # Sort ascending
desc # Sort descending
end
Valid Projections:
-
Atoms:
:field_name, converts toPrism.key(:field_name) -
Lenses:
Lens.key(:field),Lens.path([:a, :b]) -
Prisms:
Prism.key(:field),Prism.path([{Struct, :field}]) -
Functions:
&String.length/1,fn x -> x.field end -
Helper functions:
MyModule.my_projection() -
Behaviour modules:
MyBehaviour -
Bare struct modules:
MyStruct(for type filtering)
Options:
-
or_else: value- Fallback value for nil (only with atoms and Prisms)
Setup
use Funx.Ord
alias Funx.Optics.{Lens, Prism}
alias Funx.Ord
alias Funx.List
Let’s create some test data:
defmodule Person do
defstruct [:name, :age, :score, :address]
end
defmodule Address do
defstruct [:city, :state, :zip]
end
alice = %Person{name: "Alice", age: 30, score: 100}
bob = %Person{name: "Bob", age: 25, score: 50}
charlie = %Person{name: "Charlie", age: 30, score: nil}
Basic Atom Projections
The simplest projections use atoms to reference struct fields.
Ascending Sort
ord_by_name =
ord do
asc :name
end
List.sort([bob, alice, charlie], ord_by_name)
Descending Sort
ord_by_age_desc =
ord do
desc :age
end
List.sort([bob, alice, charlie], ord_by_age_desc)
Multiple Fields (Tie-Breaking)
When the first projection results in a tie, subsequent projections act as tie-breakers:
# Alice and Charlie both have age 30, so we break ties by name
ord_age_then_name =
ord do
asc :age
asc :name
end
List.sort([charlie, alice, bob], ord_age_then_name)
Mixed Directions
ord_mixed =
ord do
desc :age
asc :name
end
List.sort([charlie, alice, bob], ord_mixed)
Handling Nil Values
Atom projections use Prism.key/1 under the hood, which treats missing keys and nil values as Nothing.
Default Behavior: Nothing < Just
When using bare Prisms (including atoms), Maybe.lift_ord orders Nothing before Just with asc, and after Just with desc:
ord_by_score =
ord do
asc :score
end
# Charlie's nil score comes first (Nothing < Just)
List.sort([alice, bob, charlie], ord_by_score)
Providing Fallbacks with or_else
Use or_else to replace Nothing (from missing keys or nil values) with a default value:
ord_score_with_default =
ord do
asc :score, or_else: 1000
end
# Charlie's nil becomes 1000, so: 1000 > 100 > 50
List.sort([alice, bob, charlie], ord_score_with_default)
Explicit Lens Projections
Lenses provide total access - they assume fields exist. Lens.key/1 raises KeyError on missing keys. Lens.path/1 raises on missing keys or nil intermediate values in the path.
Simple Fields
ord_by_age =
ord do
asc Lens.key(:age)
end
List.sort([alice, bob, charlie], ord_by_age)
Nested Paths
alice_tx = %Person{
name: "Alice",
address: %Address{city: "Austin", state: "TX"}
}
bob_ma = %Person{
name: "Bob",
address: %Address{city: "Boston", state: "MA"}
}
ord_by_city =
ord do
asc Lens.path([:address, :city])
end
List.sort([bob_ma, alice_tx], ord_by_city)
Lens vs Prism: Different Nil Handling
This is a critical difference:
defmodule Container do
defstruct [:value]
end
c1 = %Container{value: nil}
c2 = %Container{value: 10}
# Lens: Extracts nil, then uses Ord protocol (Elixir term ordering: nil > 10)
ord_lens =
ord do
asc Lens.key(:value)
end
Ord.compare(c1, c2, ord_lens)
# Prism: Returns Nothing for nil, then Maybe.lift_ord (Nothing < Just)
ord_prism =
ord do
asc :value
end
Ord.compare(c1, c2, ord_prism)
Lens vs Prism:
- Lens - Lawful total optic. Extracts the focus unconditionally. Raises on missing keys or nil intermediate path values.
-
Prism - Lawful partial optic. Returns
Maybe-Nothingfor missing/nil,Just(value)for present. Primary use: sum types (variants). -
Atoms - Convenience syntax that uses
Prism.key/1under the hood.
Explicit Prism Projections
Prisms are primarily for sum types (variants), but also handle optional values safely.
With or_else (Tuple Syntax)
ord_score_fallback =
ord do
asc {Prism.key(:score), 0}
end
List.sort([alice, bob, charlie], ord_score_fallback)
Bare Prism (Maybe.lift_ord)
ord_bare_prism =
ord do
asc Prism.key(:score)
end
# Nothing sorts before Just with asc
List.sort([alice, bob, charlie], ord_bare_prism)
Reversed with desc
ord_bare_prism_desc =
ord do
desc Prism.key(:score)
end
# Nothing sorts after Just with desc (reversed)
List.sort([alice, bob, charlie], ord_bare_prism_desc)
Function Projections
Use functions for custom transformations.
Anonymous Functions
people_with_bio = [
%Person{name: "Alice", score: 100, address: "Software engineer at Acme"},
%Person{name: "Bob", score: 50, address: "Developer"}
]
ord_bio_length =
ord do
asc &String.length(&1.address)
end
List.sort(people_with_bio, ord_bio_length)
Captured Functions
strings = ["apple", "kiwi", "banana", "pear"]
ord_by_length =
ord do
asc &String.length/1
end
List.sort(strings, ord_by_length)
Helper Function Projections
Define reusable projections as 0-arity helper functions.
defmodule ProjectionHelpers do
alias Funx.Optics.{Lens, Prism}
def name_lens, do: Lens.key(:name)
def age_lens, do: Lens.key(:age)
def score_prism, do: Prism.key(:score)
def score_with_default, do: {Prism.key(:score), 0}
end
Using Helper Functions
ord_by_helpers =
ord do
asc ProjectionHelpers.age_lens()
asc ProjectionHelpers.name_lens()
end
List.sort([charlie, alice, bob], ord_by_helpers)
Helper Functions with or_else
ord_score_helper =
ord do
asc ProjectionHelpers.score_prism(), or_else: 0
end
List.sort([alice, bob, charlie], ord_score_helper)
Behaviour Module Projections
For complex projection logic, implement the Funx.Ord.Dsl.Behaviour.
defmodule NameLength do
@behaviour Funx.Ord.Dsl.Behaviour
alias Funx.Ord
@impl true
def ord(_opts) do
Ord.contramap(fn person -> String.length(person.name) end)
end
end
defmodule WeightedScore do
@behaviour Funx.Ord.Dsl.Behaviour
alias Funx.Ord
@impl true
def ord(opts) do
weight = Keyword.get(opts, :weight, 1.0)
Ord.contramap(fn person -> (person.score || 0) * weight end)
end
end
Using Behaviour Modules
ord_name_length =
ord do
asc NameLength
end
List.sort([alice, bob, charlie], ord_name_length)
With Options
ord_weighted =
ord do
desc WeightedScore, weight: 2.0
end
List.sort([alice, bob, charlie], ord_weighted)
Bare Struct Modules (Type Filtering)
Bare struct modules (e.g., asc MyStruct) compile to a predicate function that returns true for matching types, false otherwise. This partitions heterogeneous lists by type without comparing struct field values.
defmodule CreditCard do
defstruct [:name, :number, :amount]
end
defmodule Check do
defstruct [:name, :routing_number, :amount]
end
payments = [
%CreditCard{name: "Alice", number: "4242", amount: 300},
%Check{name: "Frank", routing_number: "111000025", amount: 100},
%CreditCard{name: "Bob", number: "4111", amount: 100},
%Check{name: "Edith", routing_number: "121042882", amount: 400}
]
Type-Based Sorting
# Sort: Checks first, then by name
ord_checks_first =
ord do
desc Check
asc Lens.key(:name)
end
List.sort(payments, ord_checks_first)
This puts all Check structs before all CreditCard structs, then sorts each group by name.
Complex Compositions
Combine multiple projection types for sophisticated sorting.
complex_people = [
%Person{name: "Charlie", age: 30, score: nil},
%Person{name: "Alice", age: 25, score: 100},
%Person{name: "Bob", age: 30, score: 50},
%Person{name: "Alice", age: 30, score: 100}
]
ord_complex =
ord do
asc :name
desc :age
asc :score, or_else: 0
end
List.sort(complex_people, ord_complex)
Working with Nested Data
defmodule Company do
defstruct [:name, :address]
end
defmodule Employee do
defstruct [:name, :company]
end
employees = [
%Employee{
name: "Alice",
company: %Company{
name: "Acme",
address: %Address{city: "Austin", state: "TX"}
}
},
%Employee{
name: "Bob",
company: %Company{
name: "Widgets Inc",
address: %Address{city: "Boston", state: "MA"}
}
}
]
ord_by_company_city =
ord do
asc Lens.path([:company, :address, :city])
end
List.sort(employees, ord_by_company_city)
Empty Ord (Identity Ordering)
An empty ord block returns an identity ordering where all values are equal:
ord_identity =
ord do
end
Ord.compare(alice, bob, ord_identity)
This always returns :eq, treating all values as equal.
Using with Enum.sort/2
Wrap the Ord in a comparator function for use with Enum.sort/2:
ord_by_age =
ord do
asc :age
end
Enum.sort([charlie, alice, bob], Ord.comparator(ord_by_age))
Finding Min and Max
ord_by_score =
ord do
asc :score, or_else: 0
end
List.min!([alice, bob, charlie], ord_by_score)
List.max!([alice, bob, charlie], ord_by_score)
Compile-Time Validation
The DSL validates projections at compile time:
Invalid Projection Type
# This will raise a CompileError:
ord do
asc %{invalid: :map}
end
Incorrect or_else Usage
# or_else only works with atoms and Prisms, not Lens:
ord do
asc Lens.key(:name), or_else: "Unknown"
end
Redundant or_else
# Can't use or_else when the projection already has one:
ord do
asc {Prism.key(:score), 0}, or_else: 10
end
Summary
The Ord DSL provides a declarative, type-safe way to build complex orderings:
- Atoms as projections for simple field access (total ordering via Nothing < Just)
- Lenses for total projections (raises on missing keys or nil intermediate values)
- Prisms for partial projections (Nothing < Just semantics)
- Functions for custom transformations
- Behaviours for reusable ordering logic
- Bare structs for type-based partitioning
All projections compose lexicographically, making it easy to express “sort by X, then by Y” logic without nested comparisons or multiple sort passes.