Powered by AppSignal & Oban Pro

Funx.Predicate DSL

livebooks/predicate/pred_dsl.livemd

Funx.Predicate DSL

Mix.install([
  {:funx, "0.8.4"}
])

Overview

The Funx.Predicate module provides a declarative DSL for building complex predicates (boolean algebra) by composing conditions with logical operators.

Key Features:

  • Declarative predicate composition with implicit AND
  • negate directive for logical negation
  • check directive for projection-based predicates
  • Nested any (OR) and all (AND) blocks for complex logic
  • Built-in predicates for common checks
  • Works seamlessly with Enum.filter/2, Enum.find/2, and other predicate-accepting functions

Mental Model

  • A predicate is a function a -> boolean
  • pred builds a predicate by composing smaller predicates
  • Top-level composition is AND
  • any is OR, all is AND
  • Empty any = false, empty all = true
  • negate X = NOT X
  • check projection, pred applies pred only if the projection matches. Otherwise, the check returns false.
  • check projection (no predicate) defaults to truthy check (!!value)
  • Projections never pass nil to predicates

Quick Reference

DSL Syntax

pred do
                         # Bare predicate must pass
  negate                 # Predicate must fail
  check                 # Projection + default truthy check (!!value)
  check ,         # Projection + predicate composition
  negate check ,  # Negated projection (value must NOT match)
  any do ... end                    # At least one nested predicate must pass (OR)
  all do ... end                    # All nested predicates must pass (AND)
  negate_all do ... end             # NOT (all predicates pass) - De Morgan's Laws
  negate_any do ... end             # NOT (any predicate passes) - De Morgan's Laws
end

Valid Predicates

  • Functions: &adult?/1, fn user -> user.age >= 18 end
  • Variables: is_verified, check_active
  • Behaviour modules: IsActive, {HasMinimumAge, minimum: 21}
  • Built-in predicates: Required, Integer, {Eq, value: :active}, {MinLength, min: 3}

Valid Projections

  • Atoms: :field_namePrism.key(:field_name) (present | absent)
  • Lists: [:a, :b]Prism.path([:a, :b]) (nested keys and structs)
  • Lenses: Lens.key(:field) (total accessor - always succeeds)
  • Prisms: Prism.struct(Variant) (branch selection)
  • Traversals: Traversal.combine([...]) (multiple foci as list)
  • Functions: &(&1.field), fn x -> x.value end

Setup

use Funx.Predicate
alias Funx.Optics.{Lens, Prism, Traversal}
defmodule User do
  defstruct [:name, :age, :active, :verified, :email, :role]
end

defmodule Order do
  defstruct [:id, :total, :status, :items]
end

defmodule OrderStatus.Pending do
  defstruct []
end

defmodule OrderStatus.Completed do
  defstruct [:total, :completed_at]
end

defmodule OrderStatus.Cancelled do
  defstruct [:reason]
end
alice = %User{name: "Alice", age: 30, active: true, verified: true, email: "alice@example.com", role: :admin}
bob = %User{name: "Bob", age: 17, active: true, verified: false, email: "bob@example.com", role: :user}
charlie = %User{name: "Charlie", age: 25, active: false, verified: true, email: "charlie@example.com", role: :moderator}

users = [alice, bob, charlie]

Basic Predicates

Multiple predicates at top level are combined with AND:

adult? = fn user -> user.age >= 18 end
active? = fn user -> user.active end
verified? = fn user -> user.verified end

check_eligible =
  pred do
    adult?
    active?
    verified?
  end

# Only Alice passes all three
Enum.filter(users, check_eligible)

negate: Logical Negation

minor? = fn user -> user.age < 18 end

check_adult_active =
  pred do
    negate minor?
    active?
  end

Enum.filter(users, check_adult_active)

check: Projection-Based Predicates

The check directive composes a projection with a predicate.

Atom Fields (Prism.key)

Atoms convert to Prism.key/1. When field is absent or nil, check returns false:

check_long_name =
  pred do
    check :name, fn name -> String.length(name) > 5 end
  end

check_long_name.(alice)    # true - "Alice" has 5 chars... wait, that's exactly 5
check_long_name.(charlie)  # true - "Charlie" > 5
check_long_name.(%User{name: nil})  # false - nil, predicate never runs

Lens (Total Access)

Lenses always succeed - use for required fields:

check_adult_by_age =
  pred do
    check Lens.key(:age), fn age -> age >= 18 end
  end

check_adult_by_age.(alice)  # true
check_adult_by_age.(bob)    # false

Prism (Sum Type Branch Selection)

Prisms select one case of a sum type. Predicate only runs when branch matches:

order_pending = %Order{id: 1, status: %OrderStatus.Pending{}}
order_completed = %Order{id: 2, status: %OrderStatus.Completed{total: 500, completed_at: ~U[2024-01-15 10:00:00Z]}}
order_cancelled = %Order{id: 3, status: %OrderStatus.Cancelled{reason: "Out of stock"}}

check_high_value_completed =
  pred do
    check Prism.path([:status, OrderStatus.Completed, :total]), fn total -> total >= 500 end
  end

check_high_value_completed.(order_completed)  # true - branch matches, total >= 500
check_high_value_completed.(order_pending)    # false - branch doesn't match, excluded

List Paths (Nested Access)

defmodule Account do
  defstruct [:owner, :settings]
end

defmodule Owner do
  defstruct [:name, :age]
end

defmodule Settings do
  defstruct [:notifications, :privacy]
end
accounts = [
  %Account{owner: %Owner{name: "Alice", age: 30}, settings: %Settings{notifications: true, privacy: :public}},
  %Account{owner: %Owner{name: "Bob", age: 17}, settings: %Settings{notifications: false, privacy: :private}},
  %Account{owner: %Owner{name: "Charlie", age: 25}, settings: %Settings{notifications: true, privacy: :private}}
]

check_adult_with_notifications =
  pred do
    check [:owner, :age], fn age -> age >= 18 end
    check [:settings, :notifications], fn n -> n == true end
  end

Enum.filter(accounts, check_adult_with_notifications)

Traversal (Relating Multiple Foci)

Traversal predicates receive a list of all focused values:

defmodule Transaction do
  defstruct [:charge_amount, :refund_amount, :status]
end

check_valid_refund =
  pred do
    check Traversal.combine([Lens.key(:charge_amount), Lens.key(:refund_amount)]), fn
      [charge, refund] -> charge == refund
      _ -> false
    end
  end

check_valid_refund.(%Transaction{charge_amount: 100, refund_amount: 100, status: :refunded})  # true
check_valid_refund.(%Transaction{charge_amount: 100, refund_amount: 50, status: :refunded})   # false

negate check: Excluding Matches

# Adult who is NOT banned (active != false)
valid_user =
  pred do
    check :age, fn age -> age >= 18 end
    negate check :active, fn active -> active == false end
  end

valid_user.(alice)    # true
valid_user.(charlie)  # false - not active

any and all: Boolean Logic

OR Logic with any

check_admin_or_verified =
  pred do
    any do
      fn user -> user.role == :admin end
      fn user -> user.verified end
    end
  end

Enum.filter(users, check_admin_or_verified)  # Alice (admin), Charlie (verified)

Nested Blocks

Active AND (admin OR (verified AND adult)):

check_complex =
  pred do
    active?
    any do
      fn user -> user.role == :admin end
      all do
        verified?
        adult?
      end
    end
  end

Enum.filter(users, check_complex)

De Morgan’s Laws

negate_all and negate_any apply De Morgan’s Laws:

  • negate_all: NOT (A AND B) → (NOT A) OR (NOT B) - passes if at least one fails
  • negate_any: NOT (A OR B) → (NOT A) AND (NOT B) - passes only if all fail
# Regular users only (not vip, not sponsor, not admin)
regular_user =
  pred do
    negate_any do
      fn user -> user.vip end
      fn user -> user.sponsor end
      fn user -> user.role == :admin end
    end
  end

regular_user.(%{vip: false, sponsor: false, role: :user})   # true
regular_user.(%{vip: true, sponsor: false, role: :user})    # false

Behaviour Modules

For reusable, configurable predicates:

defmodule HasMinimumAge do
  @behaviour Funx.Predicate.Dsl.Behaviour

  @impl true
  def pred(opts) do
    minimum = Keyword.get(opts, :minimum, 18)
    fn user -> user.age >= minimum end
  end
end

check_21_plus =
  pred do
    {HasMinimumAge, minimum: 21}
  end

check_21_plus.(alice)             # true - age 30
check_21_plus.(%User{age: 19})    # false

Built-in Predicates

Funx provides built-in predicate modules:

alias Funx.Predicate.{
  Eq, NotEq, In, NotIn,
  LessThan, LessThanOrEqual, GreaterThan, GreaterThanOrEqual,
  IsTrue, IsFalse,
  MinLength, MaxLength, Pattern,
  Integer, Positive, Negative,
  Required, Contains
}

Examples

# Equality and membership
check_status =
  pred do
    check :status, {Eq, value: :active}
    check :role, {In, values: [:admin, :moderator, :user]}
  end

check_status.(%{status: :active, role: :admin})  # true
check_status.(%{status: :inactive, role: :admin})  # false
# Comparison predicates
check_adult =
  pred do
    check :age, {GreaterThanOrEqual, value: 18}
  end

check_adult.(%{age: 21})  # true
check_adult.(%{age: 16})  # false
# String validation
valid_username =
  pred do
    check :username, {MinLength, min: 3}
    check :username, {MaxLength, max: 20}
    check :email, {Pattern, regex: ~r/@/}
  end

valid_username.(%{username: "alice", email: "alice@example.com"})  # true
valid_username.(%{username: "ab", email: "alice@example.com"})     # false
# Numeric predicates
valid_quantity =
  pred do
    check :quantity, Integer
    check :quantity, Positive
  end

valid_quantity.(%{quantity: 5})    # true
valid_quantity.(%{quantity: -1})   # false
valid_quantity.(%{quantity: 5.5})  # false

Default Truthy vs Required

When check has no predicate, it defaults to truthy (!!value):

truthy_check =
  pred do
    check :name  # equivalent to: fn v -> !!v end
  end

required_check =
  pred do
    check :name, Required  # not nil AND not ""
  end

# Empty string is truthy but not Required
truthy_check.(%{name: ""})    # true
required_check.(%{name: ""})  # false

# false is falsy but passes Required (it's a present value)
truthy_check.(%{name: false})    # false
required_check.(%{name: false})  # true
Value check :field (truthy) Required IsTrue
"hello" true true false
"" true false false
nil false false false
false false true false
true true true true
0 true true false

Integration with Enum

check_eligible =
  pred do
    check :age, {GreaterThanOrEqual, value: 18}
    check :verified, IsTrue
  end

# Filter, find, count, partition
Enum.filter(users, check_eligible)
Enum.find(users, check_eligible)
Enum.count(users, check_eligible)
Enum.split_with(users, check_eligible)

Common Mistakes

  • Assuming check :field passes nil — It never does. When a field is absent or nil, the check returns false and your predicate never runs.

  • Treating Prism like safe field access — Prism selects a branch, it doesn’t extract a field. If the value doesn’t match the selected case, the predicate is skipped entirely.

  • Expecting Traversal predicates to run per element — They receive a list. Your predicate gets [value1, value2, ...], not each value individually.

Summary

The Predicate DSL provides declarative, composable boolean filters:

  • Bare predicates for simple function composition
  • negate for logical negation
  • check directive for projection-based predicates (default truthy, or explicit predicate)
  • Lenses for total field access
  • Prisms for sum type branch selection
  • Atoms as degenerate sums (present | absent)
  • Traversals for relating multiple foci
  • Built-in predicates: Eq, In, LessThan, GreaterThan, MinLength, Pattern, Required, Integer, Positive, and more
  • any/all blocks for OR/AND logic
  • negate_all/negate_any for De Morgan’s Laws

Works seamlessly with Enum.filter, Enum.find, and other predicate-accepting functions.