Powered by AppSignal & Oban Pro

Funx.Ord.Dsl

livebooks/ord/ord_dsl.livemd

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 asc and desc directives 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 to Prism.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 - Nothing for missing/nil, Just(value) for present. Primary use: sum types (variants).
  • Atoms - Convenience syntax that uses Prism.key/1 under 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 &amp;String.length(&amp;1.address)
  end

List.sort(people_with_bio, ord_bio_length)

Captured Functions

strings = ["apple", "kiwi", "banana", "pear"]

ord_by_length =
  ord do
    asc &amp;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.