Predicate

View Source

The Predicate DSL is a builder DSL that constructs boolean predicates for later use. See the DSL Overview for the distinction between builder and pipeline DSLs.

Structure

A pred block compiles entirely at compile time to quoted AST that builds a predicate function. Unlike pipeline DSLs (Maybe, Either), there is no runtime executor—the DSL produces static composition of boolean logic that executes directly.

Internal Representation

The Predicate DSL uses two structure types to represent the predicate composition:

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

Each Step describes a single predicate check (bare predicate or projection with predicate). 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 (bare predicate)
    │   ├── Step (check :field, predicate)
    │   └── Block (any)
    │       ├── Step (predicate1)
    │       └── Step (predicate2)

Parser

The parser converts the DSL block into a tree of Step and Block structures. It normalizes all syntax into canonical types:

Bare Predicates

  • (a -> boolean) - Function predicate
  • Variable reference - Resolved at runtime
  • Module implementing Behaviour - Calls pred/1 at runtime
  • {Module, opts} - Behaviour with options
  • 0-arity helper - Runtime predicate resolution

Projection-Based Predicates (check directive)

The check directive composes a projection with a predicate. All projection syntax normalizes to one of:

  • Lens.t() - Bare lens struct
  • Prism.t() - Bare prism struct (Nothing fails the predicate)
  • (a -> b) - Projection function

Syntax sugar for projections:

  • :atomPrism.key(:atom)
  • [:a, :b]Prism.path([:a, :b]) (supports nested keys and structs)
  • Lens.key(...)Lens.key(...) (pass through)
  • Prism.key(...)Prism.key(...) (pass through)
  • fn -> ... endfn -> ... end (pass through)
  • Traversal.t() → Converted to projection function

The parser validates predicates and projections, raising compile-time errors for unsupported syntax.

Transformers

The Predicate 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 bare predicate → generate predicate call
    • If check projection, pred → compose projection with predicate
    • If negate: true → wrap in boolean negation
  3. For each Block:
    • If strategy: :all → combine children with AND logic
    • If strategy: :any → combine children with OR logic
  4. Top-level operations are implicitly combined with AND logic

Execution Model

An empty pred block compiles to a predicate that always returns true.

Each directive compiles to:

  • Bare predicate → predicate.(value)
  • check projection, predcompose_projection(projection, pred).(value)
  • negate predicatenot predicate.(value)
  • negate check proj, prednot compose_projection(projection, pred).(value)
  • all do ... endpred1.(value) and pred2.(value) and ...
  • any do ... endpred1.(value) or pred2.(value) or ...
  • negate_all do ... endnot pred1.(value) or not pred2.(value) or ... (De Morgan)
  • negate_any do ... endnot pred1.(value) and not pred2.(value) and ... (De Morgan)

Projection Composition

The check directive composes projections with predicates:

With Lens:

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

Compiles to a function that gets the value, then tests it.

With Prism:

check Prism.key(:email), fn email -> String.contains?(email, "@") end

Compiles to a function that returns false if the prism returns Nothing, otherwise tests the focused value.

With atom (sugar for Prism.key):

check :name, fn name -> String.length(name) > 5 end

Equivalent to check Prism.key(:name), fn name -> String.length(name) > 5 end.

With list path (nested fields):

check [:user, :profile, :age], fn age -> age >= 18 end

Equivalent to check Prism.path([:user, :profile, :age]), fn age -> age >= 18 end. The list path supports both atom keys and struct modules:

defmodule User, do: defstruct [:name, :profile]
defmodule Profile, do: defstruct [:age, :verified]

check_adult = pred do
  check [User, :profile, Profile, :age], fn age -> age >= 18 end
end

user = %User{name: "Alice", profile: %Profile{age: 25, verified: true}}
check_adult.(user)  # true

Compilation Example

pred do
  check :active, fn active -> active end
  any do
    check :role, fn role -> role == :admin end
    check :verified, fn verified -> verified end
  end
end

Compiles to a function equivalent to:

fn value ->
  (case Prism.preview(value, Prism.key(:active)) do
    {:ok, active} -> active
    :error -> false
  end) and
  (case Prism.preview(value, Prism.key(:role)) do
    {:ok, role} -> role == :admin
    :error -> false
  end or
  case Prism.preview(value, Prism.key(:verified)) do
    {:ok, verified} -> verified
    :error -> false
  end)
end

Behaviours

Modules participating in the Predicate DSL implement Funx.Predicate.Dsl.Behaviour. The parser detects behaviour modules and calls their pred/1 callback, which must return a predicate function.

The pred/1 callback receives:

  • opts - Keyword list of options passed in the DSL (e.g., {HasMinimumAge, minimum: 21})

Example:

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

pred do
  {HasMinimumAge, minimum: 21}
end

The parser compiles this to a call to HasMinimumAge.pred([minimum: 21]) which returns the predicate function.

Boolean Logic

The Predicate DSL supports two composition strategies:

All (AND Logic)

Using bare predicates or explicit all blocks creates AND composition where all predicates must pass:

pred do
  is_active
  is_verified
  is_adult
end

Equivalent to:

pred do
  all do
    is_active
    is_verified
    is_adult
  end
end

Any (OR Logic)

Using any blocks creates OR composition where at least one predicate must pass:

pred do
  any do
    is_admin
    is_moderator
  end
end

Nesting

Blocks can be nested arbitrarily deep for complex logic:

pred do
  is_active
  any do
    is_admin
    all do
      is_verified
      is_adult
    end
  end
end

This reads as: "active AND (admin OR (verified AND adult))"

Negation

The Predicate DSL supports negation at multiple levels using the negate, negate_all, and negate_any directives.

Simple Negation

Use negate to invert any bare predicate:

pred do
  negate is_banned
end

Compiles to: not is_banned.(value)

Negating Projections

Use negate check to test that a projected value does NOT match a condition:

pred do
  negate check :age, fn age -> age < 18 end
end

This is equivalent to checking that age >= 18, but handles missing fields safely (returns true if field is missing).

Negating Blocks (De Morgan's Laws)

The negate_all and negate_any directives apply De Morgan's Laws to negate entire blocks:

negate_all - NOT (A AND B) = (NOT A) OR (NOT B)

pred do
  negate_all do
    is_adult
    is_verified
  end
end

Compiles to: not is_adult.(value) or not is_verified.(value)

Returns true if at least one condition fails.

negate_any - NOT (A OR B) = (NOT A) AND (NOT B)

pred do
  negate_any do
    is_vip
    is_admin
  end
end

Compiles to: not is_vip.(value) and not is_admin.(value)

Returns true only if all conditions fail (regular user, not special).

Parser Transformation

The parser applies De Morgan's Laws at compile time:

  • negate_all do ... endBlock{strategy: :any, children: [negated...]}
  • negate_any do ... endBlock{strategy: :all, children: [negated...]}

This means negated blocks transform into their logical equivalent without requiring runtime negation of the entire block result.

Execution Model (Updated)

Each directive compiles to:

  • Bare predicate → predicate.(value)
  • check projection, predcompose_projection(projection, pred).(value)
  • negate predicatenot predicate.(value)
  • negate check proj, prednot compose_projection(projection, pred).(value)
  • all do ... endpred1.(value) and pred2.(value) and ...
  • any do ... endpred1.(value) or pred2.(value) or ...
  • negate_all do ... endnot pred1.(value) or not pred2.(value) or ...
  • negate_any do ... endnot pred1.(value) and not pred2.(value) and ...

Integration with Enum

Predicates built with the DSL work seamlessly with Elixir's Enum module:

check_eligible = pred do
  check :age, fn age -> age >= 18 end
  check :verified, fn verified -> verified end
end

# Filter
Enum.filter(users, check_eligible)

# Find
Enum.find(users, check_eligible)

# Count
Enum.count(users, check_eligible)

# Any/All
Enum.any?(users, check_eligible)
Enum.all?(users, check_eligible)

# Partition
Enum.split_with(users, check_eligible)