Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .formatter.exs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export_locals_without_parens = [
all: 1,
# Predicate DSL
pred: 1,
check: 1,
check: 2,
negate: 1,
negate_all: 1,
Expand Down
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,25 @@
# Changelog

## [0.8.3] - Unreleased

### Added

* `Funx.Predicate` – Built-in predicate modules for use in the Predicate DSL:
* `Eq` / `NotEq` – Equality and inequality checks using `Eq` comparator
* `In` / `NotIn` – List membership and exclusion checks
* `LessThan` / `LessThanOrEqual` / `GreaterThan` / `GreaterThanOrEqual` – Comparison predicates using `Ord` comparator
* `IsTrue` / `IsFalse` – Strict boolean equality checks
* `MinLength` / `MaxLength` – String length constraints
* `Pattern` – Regex pattern matching
* `Integer` / `Positive` / `Negative` – Numeric type and sign checks
* `Required` – Presence check (not nil, not empty string)
* `Contains` – List contains element check

* Predicate DSL enhancements:
* Tuple syntax support in `check` directive: `check :field, {Module, opts}`
* Bare module syntax for predicates without options: `check :field, Required`
* Default truthy check when `check` has no predicate: `check :field` (equivalent to `!!value`)

## [0.8.2] - Unreleased

### Added
Expand Down
1 change: 1 addition & 0 deletions FORMATTER_EXPORT.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ The following Eq DSL functions are configured to format without parentheses:
The following Predicate DSL functions are configured to format without parentheses:

- `pred/1` - DSL entry point for defining predicates
- `check/1` - Project and default truthy (e.g., `check :field`)
- `check/2` - Project and test a value (e.g., `check :field, predicate`)
- `negate/1` - Negate a predicate or block
- `negate_all/1` - Negate an AND block (applies De Morgan's Laws)
Expand Down
75 changes: 75 additions & 0 deletions guides/dsl/predicate.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ The parser converts the DSL block into a tree of Step and Block structures. It n
* Variable reference - Resolved at runtime
* Module implementing Behaviour - Calls `pred/1` at runtime
* `{Module, opts}` - Behaviour with options
* Built-in predicates - `Required`, `Integer`, `{Eq, value: :active}`, etc.
* 0-arity helper - Runtime predicate resolution

### Projection-Based Predicates (check directive)
Expand Down Expand Up @@ -196,6 +197,80 @@ end

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

## Built-in Predicates

Funx provides built-in predicate modules that implement `Funx.Predicate.Dsl.Behaviour`. These can be used directly in the DSL:

### Available Predicates

| Module | Required Option | Description |
|--------|----------------|-------------|
| `Eq` | `value:` | Checks equality using `Eq` comparator |
| `NotEq` | `value:` | Checks inequality using `Eq` comparator |
| `In` | `values:` | Checks membership in a list |
| `NotIn` | `values:` | Checks exclusion from a list |
| `LessThan` | `value:` | Checks `< value` using `Ord` comparator |
| `LessThanOrEqual` | `value:` | Checks `<= value` using `Ord` comparator |
| `GreaterThan` | `value:` | Checks `> value` using `Ord` comparator |
| `GreaterThanOrEqual` | `value:` | Checks `>= value` using `Ord` comparator |
| `IsTrue` | none | Checks strict `== true` |
| `IsFalse` | none | Checks strict `== false` |
| `MinLength` | `min:` | Checks string length `>= min` |
| `MaxLength` | `max:` | Checks string length `<= max` |
| `Pattern` | `regex:` | Checks string matches regex |
| `Integer` | none | Checks `is_integer/1` |
| `Positive` | none | Checks number `> 0` |
| `Negative` | none | Checks number `< 0` |
| `Required` | none | Checks not `nil` and not `""` |
| `Contains` | `value:` | Checks list contains element |

### Usage Syntax

Predicates without required options use bare module syntax:

```elixir
pred do
check :count, Integer
check :count, Positive
end
```

Predicates with options use tuple syntax:

```elixir
pred do
check :status, {Eq, value: :active}
check :age, {GreaterThanOrEqual, value: 18}
check :name, {MinLength, min: 2}
end
```

### Default Truthy Check

When `check` is used with only a projection (no predicate), the parser inserts a default truthy check:

```elixir
pred do
check :name # equivalent to: check :name, fn v -> !!v end
end
```

The default predicate is `fn value -> !!value end`, following Elixir's truthiness rules where only `nil` and `false` are falsy.

### Required vs Truthy

The `Required` predicate differs from the default truthy check:

| Value | Default (truthy) | `Required` |
|-------|-----------------|------------|
| `"hello"` | true | true |
| `""` | true | **false** |
| `nil` | false | false |
| `false` | false | **true** |
| `0` | true | true |

Use `Required` when empty strings should fail but `false` should pass.

## Boolean Logic

The Predicate DSL supports two composition strategies:
Expand Down
47 changes: 47 additions & 0 deletions lib/predicate/contains.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
defmodule Funx.Predicate.Contains do
@moduledoc """
Predicate that checks if a collection contains a specific element using an
`Eq` comparator.

Options

- `:value` (required)
The element to search for in the collection.

- `:eq` (optional)
An equality comparator. Defaults to `Funx.Eq.Protocol`.

## Examples

use Funx.Predicate

# Check if grants list contains :poison_resistance
pred do
check [:blessing, :grants], {Contains, value: :poison_resistance}
end

# Check if tags contain a specific tag
pred do
check :tags, {Contains, value: "featured"}
end

# With custom Eq comparator
pred do
check :items, {Contains, value: target_item, eq: Item.Eq}
end
"""

@behaviour Funx.Predicate.Dsl.Behaviour

alias Funx.List

@impl true
def pred(opts) do
element = Keyword.fetch!(opts, :value)
eq = Keyword.get(opts, :eq, Funx.Eq.Protocol)

fn collection ->
is_list(collection) and List.elem?(collection, element, eq)
end
end
end
59 changes: 57 additions & 2 deletions lib/predicate/dsl/parser.ex
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,17 @@ defmodule Funx.Predicate.Dsl.Parser do
# Parse "check projection, predicate"
defp parse_entry_to_node({:check, meta, [projection_ast, predicate_ast]}, _caller_env) do
normalized_projection = normalize_projection(projection_ast)
normalized_predicate = normalize_check_predicate(predicate_ast)
metadata = extract_meta(meta)
Step.new_with_projection(normalized_projection, predicate_ast, false, metadata)
Step.new_with_projection(normalized_projection, normalized_predicate, false, metadata)
end

# Parse "check projection" (single argument) - defaults to truthy check
defp parse_entry_to_node({:check, meta, [projection_ast]}, _caller_env) do
normalized_projection = normalize_projection(projection_ast)
truthy_predicate = default_truthy_predicate()
metadata = extract_meta(meta)
Step.new_with_projection(normalized_projection, truthy_predicate, false, metadata)
end

# Parse "negate check projection, predicate" - negated projection
Expand All @@ -86,8 +95,20 @@ defmodule Funx.Predicate.Dsl.Parser do
_caller_env
) do
normalized_projection = normalize_projection(projection_ast)
normalized_predicate = normalize_check_predicate(predicate_ast)
metadata = extract_meta(meta)
Step.new_with_projection(normalized_projection, normalized_predicate, true, metadata)
end

# Parse "negate check projection" (single argument) - negated truthy check
defp parse_entry_to_node(
{:negate, meta, [{:check, _check_meta, [projection_ast]}]},
_caller_env
) do
normalized_projection = normalize_projection(projection_ast)
truthy_predicate = default_truthy_predicate()
metadata = extract_meta(meta)
Step.new_with_projection(normalized_projection, predicate_ast, true, metadata)
Step.new_with_projection(normalized_projection, truthy_predicate, true, metadata)
end

# Parse "negate predicate" - bare negation
Expand Down Expand Up @@ -167,6 +188,40 @@ defmodule Funx.Predicate.Dsl.Parser do
}
end

# Default predicate for single-argument check: truthy check
# Returns AST for `fn value -> !!value end` (truthy, not strict == true)
defp default_truthy_predicate do
quote do
fn value -> !!value end
end
end

# Normalize predicate AST in check directive
#
# Handles behaviour module tuple syntax: {Module, opts} -> Module.pred(opts)
# All other predicates pass through unchanged.
#
# Note: Unlike bare behaviour modules at top-level, we don't validate
# that the module implements the behaviour at compile time. This matches
# how the validate DSL handles validators - shape validation only.
# Runtime will fail with a clear error if the module doesn't have pred/1.
defp normalize_check_predicate({{:__aliases__, _meta, _} = module_alias, opts})
when is_list(opts) do
quote do
unquote(module_alias).pred(unquote(opts))
end
end

# Bare module reference in check: Module -> Module.pred([])
defp normalize_check_predicate({:__aliases__, _meta, _} = module_alias) do
quote do
unquote(module_alias).pred([])
end
end

# All other predicates pass through unchanged
defp normalize_check_predicate(predicate_ast), do: predicate_ast

# Normalize projection AST to canonical form
#
# Atoms are converted to Prism.key calls for safe nil handling.
Expand Down
54 changes: 54 additions & 0 deletions lib/predicate/eq.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
defmodule Funx.Predicate.Eq do
@moduledoc """
Predicate that checks if a value equals an expected value using an `Eq`
comparator.

Options

- `:value` (required)
The expected value to compare against.

- `:eq` (optional)
An equality comparator. Defaults to `Funx.Eq.Protocol`.

## Examples

use Funx.Predicate

# Check for specific value
pred do
check [:status], {Eq, value: :active}
end

# Check for struct type
pred do
check [:error], {Eq, value: CustomError}
end

# With custom Eq comparator
pred do
check [:amount], {Eq, value: expected, eq: Money.Eq}
end
"""

@behaviour Funx.Predicate.Dsl.Behaviour

alias Funx.Eq

@impl true
def pred(opts) do
expected = Keyword.fetch!(opts, :value)
eq = Keyword.get(opts, :eq, Eq.Protocol)
expected_is_module = is_atom(expected)

fn value ->
case {value, expected_is_module} do
{%{__struct__: mod}, true} ->
mod == expected

_ ->
Eq.eq?(value, expected, eq)
end
end
end
end
42 changes: 42 additions & 0 deletions lib/predicate/greater_than.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
defmodule Funx.Predicate.GreaterThan do
@moduledoc """
Predicate that checks if a value is strictly greater than a reference value
using an `Ord` comparator.

Options

- `:value` (required)
The reference value to compare against.

- `:ord` (optional)
An ordering comparator. Defaults to `Funx.Ord.Protocol`.

## Examples

use Funx.Predicate

# Check if score is greater than 0
pred do
check :score, {GreaterThan, value: 0}
end

# With custom Ord comparator
pred do
check :date, {GreaterThan, value: start_date, ord: Date.Ord}
end
"""

@behaviour Funx.Predicate.Dsl.Behaviour

alias Funx.Ord

@impl true
def pred(opts) do
reference = Keyword.fetch!(opts, :value)
ord = Keyword.get(opts, :ord, Ord.Protocol)

fn value ->
Ord.compare(value, reference, ord) == :gt
end
end
end
Loading