diff --git a/.formatter.exs b/.formatter.exs index fecb2214..6227979e 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -33,6 +33,7 @@ export_locals_without_parens = [ all: 1, # Predicate DSL pred: 1, + check: 1, check: 2, negate: 1, negate_all: 1, diff --git a/CHANGELOG.md b/CHANGELOG.md index cc94fd6d..e84af955 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/FORMATTER_EXPORT.md b/FORMATTER_EXPORT.md index 4217b3c5..83cbf558 100644 --- a/FORMATTER_EXPORT.md +++ b/FORMATTER_EXPORT.md @@ -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) diff --git a/guides/dsl/predicate.md b/guides/dsl/predicate.md index c541d3f2..d58f5281 100644 --- a/guides/dsl/predicate.md +++ b/guides/dsl/predicate.md @@ -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) @@ -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: diff --git a/lib/predicate/contains.ex b/lib/predicate/contains.ex new file mode 100644 index 00000000..f93322df --- /dev/null +++ b/lib/predicate/contains.ex @@ -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 diff --git a/lib/predicate/dsl/parser.ex b/lib/predicate/dsl/parser.ex index cf5fe29b..8e6dd25b 100644 --- a/lib/predicate/dsl/parser.ex +++ b/lib/predicate/dsl/parser.ex @@ -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 @@ -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 @@ -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. diff --git a/lib/predicate/eq.ex b/lib/predicate/eq.ex new file mode 100644 index 00000000..669658d7 --- /dev/null +++ b/lib/predicate/eq.ex @@ -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 diff --git a/lib/predicate/greater_than.ex b/lib/predicate/greater_than.ex new file mode 100644 index 00000000..0fe86264 --- /dev/null +++ b/lib/predicate/greater_than.ex @@ -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 diff --git a/lib/predicate/greater_than_or_equal.ex b/lib/predicate/greater_than_or_equal.ex new file mode 100644 index 00000000..4a249cc8 --- /dev/null +++ b/lib/predicate/greater_than_or_equal.ex @@ -0,0 +1,42 @@ +defmodule Funx.Predicate.GreaterThanOrEqual do + @moduledoc """ + Predicate that checks if a value is greater than or equal to 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 at least 0 + pred do + check :score, {GreaterThanOrEqual, value: 0} + end + + # With custom Ord comparator + pred do + check :date, {GreaterThanOrEqual, 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) in [:gt, :eq] + end + end +end diff --git a/lib/predicate/in.ex b/lib/predicate/in.ex new file mode 100644 index 00000000..efa1de22 --- /dev/null +++ b/lib/predicate/in.ex @@ -0,0 +1,54 @@ +defmodule Funx.Predicate.In do + @moduledoc """ + Predicate that checks if a value is a member of a given collection using an + `Eq` comparator. + + Options + + - `:values` (required) + The list of allowed values to compare against. + + - `:eq` (optional) + An equality comparator. Defaults to `Funx.Eq.Protocol`. + + ## Examples + + use Funx.Predicate + + # Check if status is one of allowed values + pred do + check :status, {In, values: [:active, :pending, :completed]} + end + + # Check if struct type is in list + pred do + check :event, {In, values: [Click, Scroll, Submit]} + end + + # With custom Eq comparator + pred do + check :item, {In, values: allowed_items, eq: Item.Eq} + end + """ + + @behaviour Funx.Predicate.Dsl.Behaviour + + alias Funx.List + + @impl true + def pred(opts) do + values = Keyword.fetch!(opts, :values) + eq = Keyword.get(opts, :eq, Funx.Eq.Protocol) + module_values? = is_list(values) and Enum.all?(values, &is_atom/1) + + fn value -> + case {value, module_values?} do + {%{__struct__: mod}, true} -> + mod in values + + _ -> + List.elem?(values, value, eq) + end + end + end +end diff --git a/lib/predicate/integer.ex b/lib/predicate/integer.ex new file mode 100644 index 00000000..e11592fc --- /dev/null +++ b/lib/predicate/integer.ex @@ -0,0 +1,33 @@ +defmodule Funx.Predicate.Integer do + @moduledoc """ + Predicate that checks if a value is an integer. + + Options + + None required. + + ## Examples + + use Funx.Predicate + + alias Funx.Predicate.Integer + + # Check if count is an integer + pred do + check :count, Integer + end + + # Combined with other predicates + pred do + check :quantity, Integer + check :quantity, {GreaterThan, value: 0} + end + """ + + @behaviour Funx.Predicate.Dsl.Behaviour + + @impl true + def pred(_opts \\ []) do + fn value -> is_integer(value) end + end +end diff --git a/lib/predicate/is_false.ex b/lib/predicate/is_false.ex new file mode 100644 index 00000000..72372da5 --- /dev/null +++ b/lib/predicate/is_false.ex @@ -0,0 +1,34 @@ +defmodule Funx.Predicate.IsFalse do + @moduledoc """ + Predicate that checks if a value is `false`. + + This is a convenience predicate for checking boolean flags. + Uses strict equality (`== false`), not falsiness. + + ## Examples + + use Funx.Predicate + + # Check if a flag is false + pred do + check [:bleeding, :staunched], IsFalse + end + + # Equivalent to + pred do + check [:bleeding, :staunched], fn staunched -> staunched == false end + end + + # Also equivalent to + pred do + negate check [:bleeding, :staunched], IsTrue + end + """ + + @behaviour Funx.Predicate.Dsl.Behaviour + + @impl true + def pred(_opts \\ []) do + fn value -> value == false end + end +end diff --git a/lib/predicate/is_true.ex b/lib/predicate/is_true.ex new file mode 100644 index 00000000..e4f17bf1 --- /dev/null +++ b/lib/predicate/is_true.ex @@ -0,0 +1,29 @@ +defmodule Funx.Predicate.IsTrue do + @moduledoc """ + Predicate that checks if a value is `true`. + + This is a convenience predicate for checking boolean flags. + Uses strict equality (`== true`), not truthiness. + + ## Examples + + use Funx.Predicate + + # Check if a flag is true + pred do + check [:poison, :active], IsTrue + end + + # Equivalent to + pred do + check [:poison, :active], fn active -> active == true end + end + """ + + @behaviour Funx.Predicate.Dsl.Behaviour + + @impl true + def pred(_opts \\ []) do + fn value -> value == true end + end +end diff --git a/lib/predicate/less_than.ex b/lib/predicate/less_than.ex new file mode 100644 index 00000000..6002755c --- /dev/null +++ b/lib/predicate/less_than.ex @@ -0,0 +1,42 @@ +defmodule Funx.Predicate.LessThan do + @moduledoc """ + Predicate that checks if a value is strictly less 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 less than 100 + pred do + check :score, {LessThan, value: 100} + end + + # With custom Ord comparator + pred do + check :date, {LessThan, value: cutoff_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) == :lt + end + end +end diff --git a/lib/predicate/less_than_or_equal.ex b/lib/predicate/less_than_or_equal.ex new file mode 100644 index 00000000..efc59a1a --- /dev/null +++ b/lib/predicate/less_than_or_equal.ex @@ -0,0 +1,42 @@ +defmodule Funx.Predicate.LessThanOrEqual do + @moduledoc """ + Predicate that checks if a value is less than or equal to 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 at most 100 + pred do + check :score, {LessThanOrEqual, value: 100} + end + + # With custom Ord comparator + pred do + check :date, {LessThanOrEqual, value: deadline, 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) in [:lt, :eq] + end + end +end diff --git a/lib/predicate/max_length.ex b/lib/predicate/max_length.ex new file mode 100644 index 00000000..b802b4d3 --- /dev/null +++ b/lib/predicate/max_length.ex @@ -0,0 +1,37 @@ +defmodule Funx.Predicate.MaxLength do + @moduledoc """ + Predicate that checks if a string does not exceed a maximum length. + + Options + + - `:max` (required) + Maximum length (integer). + + ## Examples + + use Funx.Predicate + + # Check if name is at most 100 characters + pred do + check :name, {MaxLength, max: 100} + end + + # Combined with other predicates + pred do + check :password, {MinLength, min: 8} + check :password, {MaxLength, max: 128} + end + """ + + @behaviour Funx.Predicate.Dsl.Behaviour + + @impl true + def pred(opts) do + max = Keyword.fetch!(opts, :max) + + fn + string when is_binary(string) -> String.length(string) <= max + _non_string -> false + end + end +end diff --git a/lib/predicate/min_length.ex b/lib/predicate/min_length.ex new file mode 100644 index 00000000..57160299 --- /dev/null +++ b/lib/predicate/min_length.ex @@ -0,0 +1,37 @@ +defmodule Funx.Predicate.MinLength do + @moduledoc """ + Predicate that checks if a string meets a minimum length requirement. + + Options + + - `:min` (required) + Minimum length (integer). + + ## Examples + + use Funx.Predicate + + # Check if name is at least 2 characters + pred do + check :name, {MinLength, min: 2} + end + + # Combined with other predicates + pred do + check :password, {MinLength, min: 8} + check :password, {MaxLength, max: 128} + end + """ + + @behaviour Funx.Predicate.Dsl.Behaviour + + @impl true + def pred(opts) do + min = Keyword.fetch!(opts, :min) + + fn + string when is_binary(string) -> String.length(string) >= min + _non_string -> false + end + end +end diff --git a/lib/predicate/negative.ex b/lib/predicate/negative.ex new file mode 100644 index 00000000..580bdad5 --- /dev/null +++ b/lib/predicate/negative.ex @@ -0,0 +1,36 @@ +defmodule Funx.Predicate.Negative do + @moduledoc """ + Predicate that checks if a number is strictly negative (< 0). + + Returns false for non-numbers. + + Options + + None required. + + ## Examples + + use Funx.Predicate + + # Check if balance is negative + pred do + check :balance, Negative + end + + # Combined with other predicates + pred do + check :adjustment, IsInteger + check :adjustment, Negative + end + """ + + @behaviour Funx.Predicate.Dsl.Behaviour + + @impl true + def pred(_opts \\ []) do + fn + number when is_number(number) -> number < 0 + _non_number -> false + end + end +end diff --git a/lib/predicate/not_eq.ex b/lib/predicate/not_eq.ex new file mode 100644 index 00000000..7fd2a6fe --- /dev/null +++ b/lib/predicate/not_eq.ex @@ -0,0 +1,54 @@ +defmodule Funx.Predicate.NotEq do + @moduledoc """ + Predicate that checks if a value does not equal an expected value using an + `Eq` comparator. + + Options + + - `:value` (required) + The value to compare against. + + - `:eq` (optional) + An equality comparator. Defaults to `Funx.Eq.Protocol`. + + ## Examples + + use Funx.Predicate + + # Check that status is not deleted + pred do + check :status, {NotEq, value: :deleted} + end + + # Check that struct is not a specific error type + pred do + check :error, {NotEq, value: FatalError} + end + + # With custom Eq comparator + pred do + check :amount, {NotEq, value: zero, 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.not_eq?(value, expected, eq) + end + end + end +end diff --git a/lib/predicate/not_in.ex b/lib/predicate/not_in.ex new file mode 100644 index 00000000..e50d0057 --- /dev/null +++ b/lib/predicate/not_in.ex @@ -0,0 +1,54 @@ +defmodule Funx.Predicate.NotIn do + @moduledoc """ + Predicate that checks if a value is not a member of a given collection using + an `Eq` comparator. + + Options + + - `:values` (required) + The list of disallowed values. + + - `:eq` (optional) + An equality comparator. Defaults to `Funx.Eq.Protocol`. + + ## Examples + + use Funx.Predicate + + # Check if status is not one of disallowed values + pred do + check :status, {NotIn, values: [:deleted, :archived]} + end + + # Check if struct type is not in list + pred do + check :event, {NotIn, values: [DeprecatedEvent, LegacyEvent]} + end + + # With custom Eq comparator + pred do + check :item, {NotIn, values: blocked_items, eq: Item.Eq} + end + """ + + @behaviour Funx.Predicate.Dsl.Behaviour + + alias Funx.List + + @impl true + def pred(opts) do + values = Keyword.fetch!(opts, :values) + eq = Keyword.get(opts, :eq, Funx.Eq.Protocol) + module_values? = is_list(values) and Enum.all?(values, &is_atom/1) + + fn value -> + case {value, module_values?} do + {%{__struct__: mod}, true} -> + mod not in values + + _ -> + not List.elem?(values, value, eq) + end + end + end +end diff --git a/lib/predicate/pattern.ex b/lib/predicate/pattern.ex new file mode 100644 index 00000000..f18783e9 --- /dev/null +++ b/lib/predicate/pattern.ex @@ -0,0 +1,41 @@ +defmodule Funx.Predicate.Pattern do + @moduledoc """ + Predicate that checks if a string matches a regular expression pattern. + + Returns false for non-strings. + + Options + + - `:regex` (required) + Regular expression pattern (Regex.t()). + + ## Examples + + use Funx.Predicate + + alias Funx.Predicate.Pattern + + # Check if code matches pattern + pred do + check :code, {Pattern, regex: ~r/^[A-Z]{3}$/} + end + + # Combined with other predicates + pred do + check :email, Required + check :email, {Pattern, regex: ~r/@/} + end + """ + + @behaviour Funx.Predicate.Dsl.Behaviour + + @impl true + def pred(opts) do + regex = Keyword.fetch!(opts, :regex) + + fn + string when is_binary(string) -> Regex.match?(regex, string) + _non_string -> false + end + end +end diff --git a/lib/predicate/positive.ex b/lib/predicate/positive.ex new file mode 100644 index 00000000..2bc8e28d --- /dev/null +++ b/lib/predicate/positive.ex @@ -0,0 +1,36 @@ +defmodule Funx.Predicate.Positive do + @moduledoc """ + Predicate that checks if a number is strictly positive (> 0). + + Returns false for non-numbers. + + Options + + None required. + + ## Examples + + use Funx.Predicate + + # Check if amount is positive + pred do + check :amount, Positive + end + + # Combined with integer check + pred do + check :count, IsInteger + check :count, Positive + end + """ + + @behaviour Funx.Predicate.Dsl.Behaviour + + @impl true + def pred(_opts \\ []) do + fn + number when is_number(number) -> number > 0 + _non_number -> false + end + end +end diff --git a/lib/predicate/required.ex b/lib/predicate/required.ex new file mode 100644 index 00000000..ebe5123e --- /dev/null +++ b/lib/predicate/required.ex @@ -0,0 +1,41 @@ +defmodule Funx.Predicate.Required do + @moduledoc """ + Predicate that checks if a value is present (not nil and not empty string). + + This predicate returns true for all values except: + - `nil` + - `""` (empty string) + + Note that falsy values like `0`, `false`, and `[]` are considered present. + + Options + + None required. + + ## Examples + + use Funx.Predicate + + alias Funx.Predicate.Required + + # Check if name has a value + pred do + check :name, Required + end + + # Different from truthy - false and 0 pass + pred do + check :enabled, Required # false passes + check :count, Required # 0 passes + end + """ + + @behaviour Funx.Predicate.Dsl.Behaviour + + @impl true + def pred(_opts \\ []) do + fn value -> + not is_nil(value) and value != "" + end + end +end diff --git a/lib/validator/any.ex b/lib/validator/any.ex index a5e48fa0..99e80661 100644 --- a/lib/validator/any.ex +++ b/lib/validator/any.ex @@ -64,17 +64,14 @@ defmodule Funx.Validator.Any do alias Funx.Errors.ValidationError alias Funx.Monad.Either - # Convenience overload for default opts (raises on missing required options) def validate(value) do validate(value, []) end - # Convenience overload for easier direct usage def validate(value, opts) when is_list(opts) do validate(value, opts, %{}) end - # Behaviour implementation (arity-3) @impl true def validate(value, opts, env) do validators = Keyword.get(opts, :validators) @@ -91,26 +88,20 @@ defmodule Funx.Validator.Any do |> finalize(opts) end - # Run validator with options defp run({validator, opts}, value, env) do cond do - # Function validator with options is_function(validator, 3) -> validator.(value, opts, env) is_function(validator, 2) -> validator.(value, opts) is_function(validator, 1) -> validator.(value) - # Module validator with options true -> validator.validate(value, opts, env) end end - # Run validator without options defp run(validator, value, env) do cond do - # Function validator without options is_function(validator, 3) -> validator.(value, [], env) is_function(validator, 2) -> validator.(value, []) is_function(validator, 1) -> validator.(value) - # Module validator without options true -> validator.validate(value, [], env) end end diff --git a/lib/validator/contains.ex b/lib/validator/contains.ex new file mode 100644 index 00000000..7a6438a0 --- /dev/null +++ b/lib/validator/contains.ex @@ -0,0 +1,46 @@ +defmodule Funx.Validator.Contains do + @moduledoc """ + Validates that a collection contains a specific element using an `Eq` + comparator. + + `Contains` enforces a membership constraint of the form: + "collection must contain X". + + Membership is defined by an `Eq` instance, not by structural equality or + Elixir's `in` operator. + + Options + + - `:value` (required) + The element that must be present in the collection. + + - `:eq` (optional) + An equality comparator. Defaults to `Funx.Eq.Protocol`. + + - `:message` (optional) + A custom error message callback `(value -> String.t())`. + + Semantics + + - Succeeds if the collection is a list and contains the element under `Eq`. + - Fails if the collection does not contain the element or is not a list. + - `Nothing` values pass unchanged. + - `Just` values are unwrapped before validation. + """ + + use Funx.Validator + + alias Funx.Predicate + + @impl Funx.Validator + def valid?(value, opts, _env) do + predicate = Predicate.Contains.pred(opts) + predicate.(value) + end + + @impl Funx.Validator + def default_message(_value, opts) do + element = Keyword.fetch!(opts, :value) + "must contain #{inspect(element)}" + end +end diff --git a/lib/validator/equal.ex b/lib/validator/equal.ex index 8b1a3f9b..9fd750b9 100644 --- a/lib/validator/equal.ex +++ b/lib/validator/equal.ex @@ -33,21 +33,12 @@ defmodule Funx.Validator.Equal do use Funx.Validator - alias Funx.Eq + alias Funx.Predicate @impl Funx.Validator def valid?(value, opts, _env) do - expected = Keyword.fetch!(opts, :value) - eq = Keyword.get(opts, :eq, Eq.Protocol) - expected_is_module = is_atom(expected) - - case {value, expected_is_module} do - {%{__struct__: mod}, true} -> - mod == expected - - _ -> - Eq.eq?(value, expected, eq) - end + predicate = Predicate.Eq.pred(opts) + predicate.(value) end @impl Funx.Validator diff --git a/lib/validator/greater_than.ex b/lib/validator/greater_than.ex index 69eee645..d1d5b3e9 100644 --- a/lib/validator/greater_than.ex +++ b/lib/validator/greater_than.ex @@ -50,13 +50,12 @@ defmodule Funx.Validator.GreaterThan do use Funx.Validator - alias Funx.Ord + alias Funx.Predicate @impl Funx.Validator def valid?(value, opts, _env) do - reference = Keyword.fetch!(opts, :value) - ord = Keyword.get(opts, :ord, Ord.Protocol) - Ord.compare(value, reference, ord) == :gt + predicate = Predicate.GreaterThan.pred(opts) + predicate.(value) end @impl Funx.Validator diff --git a/lib/validator/greater_than_or_eq.ex b/lib/validator/greater_than_or_eq.ex index 79089e05..0e114693 100644 --- a/lib/validator/greater_than_or_eq.ex +++ b/lib/validator/greater_than_or_eq.ex @@ -19,13 +19,12 @@ defmodule Funx.Validator.GreaterThanOrEqual do use Funx.Validator - alias Funx.Ord + alias Funx.Predicate @impl Funx.Validator def valid?(value, opts, _env) do - reference = Keyword.fetch!(opts, :value) - ord = Keyword.get(opts, :ord, Ord.Protocol) - Ord.compare(value, reference, ord) in [:gt, :eq] + predicate = Predicate.GreaterThanOrEqual.pred(opts) + predicate.(value) end @impl Funx.Validator diff --git a/lib/validator/in.ex b/lib/validator/in.ex index f046e364..0aa191f4 100644 --- a/lib/validator/in.ex +++ b/lib/validator/in.ex @@ -31,9 +31,9 @@ defmodule Funx.Validator.In do @behaviour Funx.Validate.Behaviour - alias Funx.List alias Funx.Monad.Either alias Funx.Monad.Maybe.{Just, Nothing} + alias Funx.Predicate alias Funx.Validator def validate(value) do @@ -61,20 +61,11 @@ defmodule Funx.Validator.In do defp validate_value(value, opts) do values = Keyword.fetch!(opts, :values) - eq = Keyword.get(opts, :eq, Funx.Eq.Protocol) - module_values? = is_list(values) and Enum.all?(values, &is_atom/1) + predicate = Predicate.In.pred(opts) Either.lift_predicate( value, - fn v -> - case {v, module_values?} do - {%{__struct__: mod}, true} -> - mod in values - - _ -> - List.elem?(values, v, eq) - end - end, + predicate, fn v -> rendered = Enum.map_join(values, ", ", &inspect/1) Validator.validation_error(opts, v, "must be one of: #{rendered}") diff --git a/lib/validator/integer.ex b/lib/validator/integer.ex index 7c420db6..820cc9a1 100644 --- a/lib/validator/integer.ex +++ b/lib/validator/integer.ex @@ -17,9 +17,12 @@ defmodule Funx.Validator.Integer do use Funx.Validator + alias Funx.Predicate + @impl Funx.Validator - def valid?(value, _opts, _env) do - is_integer(value) + def valid?(value, opts, _env) do + predicate = Predicate.Integer.pred(opts) + predicate.(value) end @impl Funx.Validator diff --git a/lib/validator/less_than.ex b/lib/validator/less_than.ex index f7c78640..02d9a155 100644 --- a/lib/validator/less_than.ex +++ b/lib/validator/less_than.ex @@ -19,13 +19,12 @@ defmodule Funx.Validator.LessThan do use Funx.Validator - alias Funx.Ord + alias Funx.Predicate @impl Funx.Validator def valid?(value, opts, _env) do - reference = Keyword.fetch!(opts, :value) - ord = Keyword.get(opts, :ord, Ord.Protocol) - Ord.compare(value, reference, ord) == :lt + predicate = Predicate.LessThan.pred(opts) + predicate.(value) end @impl Funx.Validator diff --git a/lib/validator/less_than_or_eq.ex b/lib/validator/less_than_or_eq.ex index b87943e1..03e36a6b 100644 --- a/lib/validator/less_than_or_eq.ex +++ b/lib/validator/less_than_or_eq.ex @@ -19,13 +19,12 @@ defmodule Funx.Validator.LessThanOrEqual do use Funx.Validator - alias Funx.Ord + alias Funx.Predicate @impl Funx.Validator def valid?(value, opts, _env) do - reference = Keyword.fetch!(opts, :value) - ord = Keyword.get(opts, :ord, Ord.Protocol) - Ord.compare(value, reference, ord) in [:lt, :eq] + predicate = Predicate.LessThanOrEqual.pred(opts) + predicate.(value) end @impl Funx.Validator diff --git a/lib/validator/max_length.ex b/lib/validator/max_length.ex index ffc4c1ab..38a4f158 100644 --- a/lib/validator/max_length.ex +++ b/lib/validator/max_length.ex @@ -24,14 +24,14 @@ defmodule Funx.Validator.MaxLength do use Funx.Validator + alias Funx.Predicate + @impl Funx.Validator - def valid?(string, opts, _env) when is_binary(string) do - max = Keyword.fetch!(opts, :max) - String.length(string) <= max + def valid?(value, opts, _env) do + predicate = Predicate.MaxLength.pred(opts) + predicate.(value) end - def valid?(_non_string, _opts, _env), do: false - @impl Funx.Validator def default_message(value, opts) when is_binary(value) do max = Keyword.fetch!(opts, :max) diff --git a/lib/validator/min_length.ex b/lib/validator/min_length.ex index eec70313..06b8da11 100644 --- a/lib/validator/min_length.ex +++ b/lib/validator/min_length.ex @@ -21,14 +21,14 @@ defmodule Funx.Validator.MinLength do use Funx.Validator + alias Funx.Predicate + @impl Funx.Validator - def valid?(string, opts, _env) when is_binary(string) do - min = Keyword.fetch!(opts, :min) - String.length(string) >= min + def valid?(value, opts, _env) do + predicate = Predicate.MinLength.pred(opts) + predicate.(value) end - def valid?(_non_string, _opts, _env), do: false - @impl Funx.Validator def default_message(value, opts) when is_binary(value) do min = Keyword.fetch!(opts, :min) diff --git a/lib/validator/negative.ex b/lib/validator/negative.ex index 43c57601..2593c61d 100644 --- a/lib/validator/negative.ex +++ b/lib/validator/negative.ex @@ -20,13 +20,14 @@ defmodule Funx.Validator.Negative do use Funx.Validator + alias Funx.Predicate + @impl Funx.Validator - def valid?(number, _opts, _env) when is_number(number) do - number < 0 + def valid?(value, opts, _env) do + predicate = Predicate.Negative.pred(opts) + predicate.(value) end - def valid?(_non_number, _opts, _env), do: false - @impl Funx.Validator def default_message(value, _opts) when is_number(value) do "must be negative" diff --git a/lib/validator/not_equal.ex b/lib/validator/not_equal.ex index f1e25bc3..3fd24c58 100644 --- a/lib/validator/not_equal.ex +++ b/lib/validator/not_equal.ex @@ -29,48 +29,19 @@ defmodule Funx.Validator.NotEqual do - `Just` values are unwrapped before comparison. """ - @behaviour Funx.Validate.Behaviour + use Funx.Validator - alias Funx.Eq - alias Funx.Monad.Either - alias Funx.Monad.Maybe.{Just, Nothing} - alias Funx.Validator + alias Funx.Predicate - # Convenience overload for default opts (raises on missing required options) - def validate(value) do - validate(value, []) + @impl Funx.Validator + def valid?(value, opts, _env) do + predicate = Predicate.NotEq.pred(opts) + predicate.(value) end - # Convenience overload - def validate(value, opts) when is_list(opts) do - validate(value, opts, %{}) - end - - @impl true - def validate(value, opts, env) - - def validate(%Nothing{} = value, _opts, _env) do - Either.right(value) - end - - def validate(%Just{value: value}, opts, _env) do - validate_value(value, opts) - end - - def validate(value, opts, _env) do - validate_value(value, opts) - end - - defp validate_value(value, opts) do + @impl Funx.Validator + def default_message(_value, opts) do reference = Keyword.fetch!(opts, :value) - eq = Keyword.get(opts, :eq, Eq.Protocol) - - Either.lift_predicate( - value, - fn v -> Eq.not_eq?(v, reference, eq) end, - fn v -> - Validator.validation_error(opts, v, "must not be equal to #{inspect(reference)}") - end - ) + "must not be equal to #{inspect(reference)}" end end diff --git a/lib/validator/not_in.ex b/lib/validator/not_in.ex index a9fdb877..214f65c3 100644 --- a/lib/validator/not_in.ex +++ b/lib/validator/not_in.ex @@ -44,46 +44,19 @@ defmodule Funx.Validator.NotIn do %Funx.Monad.Either.Right{right: %Funx.Monad.Maybe.Nothing{}} """ - @behaviour Funx.Validate.Behaviour + use Funx.Validator - alias Funx.List - alias Funx.Monad.Either - alias Funx.Monad.Maybe.{Just, Nothing} - alias Funx.Validator + alias Funx.Predicate - def validate(value) do - validate(value, []) + @impl Funx.Validator + def valid?(value, opts, _env) do + predicate = Predicate.NotIn.pred(opts) + predicate.(value) end - def validate(value, opts) when is_list(opts) do - validate(value, opts, %{}) - end - - @impl true - def validate(value, opts, env) - - def validate(%Nothing{} = value, _opts, _env) do - Either.right(value) - end - - def validate(%Just{value: v}, opts, _env) do - validate_value(v, opts) - end - - def validate(value, opts, _env) do - validate_value(value, opts) - end - - defp validate_value(value, opts) do + @impl Funx.Validator + def default_message(_value, opts) do values = Keyword.fetch!(opts, :values) - eq = Keyword.get(opts, :eq, Eq.Protocol) - - Either.lift_predicate( - value, - fn v -> not List.elem?(values, v, eq) end, - fn v -> - Validator.validation_error(opts, v, "must not be one of: #{inspect(values)}") - end - ) + "must not be one of: #{inspect(values)}" end end diff --git a/lib/validator/pattern.ex b/lib/validator/pattern.ex index df2fdcf2..da1c8c50 100644 --- a/lib/validator/pattern.ex +++ b/lib/validator/pattern.ex @@ -21,14 +21,14 @@ defmodule Funx.Validator.Pattern do use Funx.Validator + alias Funx.Predicate + @impl Funx.Validator - def valid?(string, opts, _env) when is_binary(string) do - regex = Keyword.fetch!(opts, :regex) - Regex.match?(regex, string) + def valid?(value, opts, _env) do + predicate = Predicate.Pattern.pred(opts) + predicate.(value) end - def valid?(_non_string, _opts, _env), do: false - @impl Funx.Validator def default_message(value, _opts) when is_binary(value) do "has invalid format" diff --git a/lib/validator/positive.ex b/lib/validator/positive.ex index fe730e9f..4caa0c31 100644 --- a/lib/validator/positive.ex +++ b/lib/validator/positive.ex @@ -20,13 +20,14 @@ defmodule Funx.Validator.Positive do use Funx.Validator + alias Funx.Predicate + @impl Funx.Validator - def valid?(number, _opts, _env) when is_number(number) do - number > 0 + def valid?(value, opts, _env) do + predicate = Predicate.Positive.pred(opts) + predicate.(value) end - def valid?(_non_number, _opts, _env), do: false - @impl Funx.Validator def default_message(value, _opts) when is_number(value) do "must be positive" diff --git a/lib/validator/required.ex b/lib/validator/required.ex index 227d7e45..81aacc80 100644 --- a/lib/validator/required.ex +++ b/lib/validator/required.ex @@ -40,9 +40,9 @@ defmodule Funx.Validator.Required do alias Funx.Monad.Either alias Funx.Monad.Maybe.Nothing + alias Funx.Predicate alias Funx.Validator - # Convenience overloads for easier direct usage def validate(value) do validate(value, [], %{}) end @@ -51,7 +51,6 @@ defmodule Funx.Validator.Required do validate(value, opts, %{}) end - # Behaviour implementation (arity-3) @impl true def validate(value, opts, env) @@ -61,9 +60,11 @@ defmodule Funx.Validator.Required do end def validate(value, opts, _env) do + predicate = Predicate.Required.pred(opts) + Either.lift_predicate( value, - fn v -> not is_nil(v) and v != "" end, + predicate, fn v -> Validator.validation_error(opts, v, "is required") end ) end diff --git a/livebooks/predicate/pred_dsl.livemd b/livebooks/predicate/pred_dsl.livemd index 54e4d624..752f64d9 100644 --- a/livebooks/predicate/pred_dsl.livemd +++ b/livebooks/predicate/pred_dsl.livemd @@ -2,29 +2,25 @@ ```elixir Mix.install([ - {:funx, "0.8.2"} + {:funx, github: "JKWA/funx", ref: "d287d9e"} ]) ``` ## Overview -The `Funx.Predicate` module provides a declarative DSL syntax for building complex predicates (boolean algebra) by composing conditions with logical operators. - -The DSL defines predicate logic that can consume projections - functions that extract values for testing. Lens and Prism optics are one family of reusable projections the DSL accepts. The boolean structure (AND/OR) allows you to express "true if X passes AND Y passes" or "true if X OR Y passes" in a clear, composable way. +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 negating predicates +* `negate` directive for logical negation * `check` directive for projection-based predicates * Nested `any` (OR) and `all` (AND) blocks for complex logic -* Support for atoms, lenses, prisms, traversals, functions, and custom behaviours +* Built-in predicates for common checks * Works seamlessly with `Enum.filter/2`, `Enum.find/2`, and other predicate-accepting functions ## Mental Model -Core rules: - * A predicate is a function `a -> boolean` * `pred` builds a predicate by composing smaller predicates * Top-level composition is AND @@ -32,12 +28,12 @@ Core rules: * 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** -* **Traversals pass a list of foci, in declared order** ## Quick Reference -### DSL Syntax: +### DSL Syntax @@ -45,51 +41,39 @@ Core rules: 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) - applies De Morgan's Laws - negate_any do ... end # NOT (any predicate passes) - applies De Morgan's Laws + 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 ``` -**Empty blocks are valid.** `any` returns false (OR identity), `all` returns true (AND identity). - -### Valid Predicates: +### Valid Predicates * Functions: `&adult?/1`, `fn user -> user.age >= 18 end` * Variables: `is_verified`, `check_active` -* Helper functions: `MyModule.adult?` * Behaviour modules: `IsActive`, `{HasMinimumAge, minimum: 21}` +* Built-in predicates: `Required`, `Integer`, `{Eq, value: :active}`, `{MinLength, min: 3}` -### Valid Projections (for `check` directive): - -* Atoms: `:field_name`, converts to `Prism.key(:field_name)` (degenerate sum: present | absent) - - * When absent or nil: the `check` is false (predicate never receives nil) - -* Lists: `[:a, :b]`, converts to `Prism.path([:a, :b])` (supports nested keys and structs) - -* Lenses: `Lens.key(:field)`, `Lens.path([:a, :b])` (total accessor - always succeeds) - -* Prisms: `Prism.struct(Variant)`, `Prism.path([:field, Variant, ...])` (branch selection) +### Valid Projections -* Traversals: `Traversal.combine([...])` (collects multiple foci into a list for the predicate) - -* Functions: `&(&1.field)`, `fn x -> x.value end` (custom extraction) +* Atoms: `:field_name` → `Prism.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 the DSL and alias helper modules: - ```elixir use Funx.Predicate alias Funx.Optics.{Lens, Prism, Traversal} ``` -Define test structs: - ```elixir defmodule User do defstruct [:name, :age, :active, :verified, :email, :role] @@ -99,11 +83,6 @@ defmodule Order do defstruct [:id, :total, :status, :items] end -defmodule Product do - defstruct [:name, :price, :in_stock, :category] -end - -# Order status as an explicit sum type (tagged union) defmodule OrderStatus.Pending do defstruct [] end @@ -117,8 +96,6 @@ defmodule OrderStatus.Cancelled do end ``` -Create sample data: - ```elixir 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} @@ -129,82 +106,29 @@ users = [alice, bob, charlie] ## Basic Predicates -The simplest predicates are functions that return boolean values. - -### Single Predicate +Multiple predicates at top level are combined with AND: ```elixir adult? = fn user -> user.age >= 18 end - -check_adult = - pred do - adult? - end - -check_adult.(alice) -``` - -```elixir -check_adult.(bob) -``` - -### Using with Enum.filter - -Predicates work seamlessly with Enum functions: - -```elixir -Enum.filter(users, check_adult) -``` - -### Multiple Predicates (Implicit AND) - -When you list multiple predicates, ALL must pass: - -```elixir active? = fn user -> user.active end verified? = fn user -> user.verified end -check_active_verified = +check_eligible = pred do + adult? active? verified? end -check_active_verified.(alice) -``` - -```elixir -check_active_verified.(bob) -``` - -### Filtering with Multiple Conditions - -```elixir -Enum.filter(users, check_active_verified) +# Only Alice passes all three +Enum.filter(users, check_eligible) ``` ## `negate`: Logical Negation -The `negate` directive inverts a predicate: - ```elixir minor? = fn user -> user.age < 18 end -check_not_minor = - pred do - negate minor? - end - -check_not_minor.(alice) -``` - -```elixir -check_not_minor.(bob) -``` - -### Combining negate with Other Predicates - -```elixir check_adult_active = pred do negate minor? @@ -216,88 +140,26 @@ Enum.filter(users, check_adult_active) ## `check`: Projection-Based Predicates -The `check` directive composes a projection with a predicate, allowing you to test focused values. - -### Optics at a Glance - -Quick comparison of projection types: - -**Lens** -Selects: exactly one value -On mismatch: never mismatches -Use when: the field must exist - -**Prism** -Selects: one case of a sum type -On mismatch: predicate is skipped, result is false -Use when: rules apply only to specific variants - -**Atom** -Equivalent to: `Prism.key/1` -Models: present | absent -Never passes `nil` +The `check` directive composes a projection with a predicate. -**Traversal** -Selects: multiple related foci -Predicate input: list -Order: declaration order +### Atom Fields (Prism.key) -### Prism: Case Selection (Not Field Access) - -**A Prism does not extract a field. It selects a case. Predicates composed with a Prism only apply when the value is in that case.** - -> **Important:** Prism excludes predicates, it does not fail them. If the value doesn't match the selected branch, the predicate is skipped entirely and the check returns false. The predicate never runs on the wrong branch. - -Prisms are designed for sum types (tagged unions) where data can be in one of several variants: - -```elixir -# Order status as a sum type: Pending | Completed | Cancelled -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"}} -``` - -When you use `check Prism.struct(OrderStatus.Completed)`, you're saying: "this rule applies **only** for completed orders." The predicate is excluded entirely for pending or cancelled orders: +Atoms convert to `Prism.key/1`. When field is absent or `nil`, check returns false: ```elixir -# Check only applies to completed orders -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 (case matches AND total >= 500) -check_high_value_completed.(order_pending) # false (case doesn't match - excluded) -``` - -**Atoms as Degenerate Sums** - -When you write `check :field_name`, it's converted to `Prism.key(:field_name)`, treating the field as a degenerate sum type: `present | absent`. This models optional fields, not safe field access. - -> **Important:** Atom projection is not field access. When a field is absent or `nil`, the check returns false and your predicate never runs. Your predicate never receives `nil`. - -### check with Atom Fields - -Atoms are automatically converted to `Prism.key` for safe nil handling: - -```elixir -is_long = fn name -> String.length(name) > 5 end - check_long_name = pred do - check :name, is_long + check :name, fn name -> String.length(name) > 5 end end -check_long_name.(alice) +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 ``` -```elixir -check_long_name.(%User{name: "Joe"}) -``` +### Lens (Total Access) -### check with Lens - -Lenses provide total access to fields: +Lenses always succeed - use for required fields: ```elixir check_adult_by_age = @@ -305,49 +167,29 @@ check_adult_by_age = check Lens.key(:age), fn age -> age >= 18 end end -check_adult_by_age.(alice) +check_adult_by_age.(alice) # true +check_adult_by_age.(bob) # false ``` -### check with Prism (Sum Type Branch Selection) +### Prism (Sum Type Branch Selection) -Prisms select branches of sum types. Use `Prism.struct/1` to match on a specific variant: +Prisms select one case of a sum type. Predicate only runs when branch matches: ```elixir -# Only apply predicate to completed orders -check_large_completed = - pred do - check Prism.path([:status, OrderStatus.Completed]), fn completed -> - completed.total >= 500 - end - end - -check_large_completed.(order_completed) # true (branch matches AND total >= 500) -``` - -```elixir -check_large_completed.(order_pending) # false (branch doesn't match - predicate excluded) -``` - -```elixir -check_large_completed.(order_cancelled) # false (branch doesn't match - predicate excluded) -``` - -### check with Function Projection +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"}} -```elixir -check_gmail = +check_high_value_completed = pred do - check &(&1.email), fn email -> String.ends_with?(email, "@example.com") end + check Prism.path([:status, OrderStatus.Completed, :total]), fn total -> total >= 500 end end -Enum.filter(users, check_gmail) +check_high_value_completed.(order_completed) # true - branch matches, total >= 500 +check_high_value_completed.(order_pending) # false - branch doesn't match, excluded ``` -### check with List Paths (Nested Field Access) - -List paths provide convenient syntax for accessing nested fields without manually composing optics. - -Define nested test data: +### List Paths (Nested Access) ```elixir defmodule Account do @@ -361,202 +203,62 @@ 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 nested fields using list syntax: - ```elixir -check_adult_owner = - pred do - check [:owner, :age], fn age -> age >= 18 end - end - -Enum.filter(accounts, check_adult_owner) -``` - -Check multiple nested fields: +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}} +] -```elixir -check_adult_notifications = +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_notifications) -``` - -List paths work with struct modules for type-safe access: - -```elixir -check_struct_path = - pred do - check [Account, :owner, Owner, :age], fn age -> age >= 21 end - end - -Enum.filter(accounts, check_struct_path) +Enum.filter(accounts, check_adult_with_notifications) ``` -Negating list path checks: - -```elixir -check_not_private = - pred do - negate check [:settings, :privacy], fn p -> p == :private end - end - -Enum.filter(accounts, check_not_private) -``` +### Traversal (Relating Multiple Foci) -List paths are syntax sugar for `Prism.path`: - -```elixir -# These are equivalent: -check_list = pred do - check [:owner, :age], fn age -> age >= 18 end -end - -check_explicit = pred do - check Prism.path([:owner, :age]), fn age -> age >= 18 end -end - -Enum.filter(accounts, check_list) == Enum.filter(accounts, check_explicit) -``` - -### check with Traversal (Relating Multiple Foci) - -**A Traversal is rarely used to test each element independently. Its real power is collecting multiple related foci so a single rule can relate them to each other.** - -When you use `check` with a Traversal, your predicate receives a **list of all the focused values**. This lets you compare, validate, or aggregate them. **The list order matches the order of optics passed to `Traversal.combine/1`.** - -> **Important:** Traversal predicates consume a list, not individual elements. Your predicate receives `[value1, value2, ...]`, not each value separately. Use pattern matching or list operations to relate the foci. - -Use Traversals when you need to relate values to each other, not just check each one independently: +Traversal predicates receive a list of all focused values: ```elixir defmodule Transaction do defstruct [:charge_amount, :refund_amount, :status] end -``` -```elixir -# Check that refund matches original charge (relating two foci) check_valid_refund = pred do - check Traversal.combine([ - Lens.key(:charge_amount), - Lens.key(:refund_amount) - ]), fn values -> - case values do - [charge, refund] -> charge == refund - _ -> false - end + check Traversal.combine([Lens.key(:charge_amount), Lens.key(:refund_amount)]), fn + [charge, refund] -> charge == refund + _ -> false end end -transaction = %Transaction{charge_amount: 100, refund_amount: 100, status: :refunded} -check_valid_refund.(transaction) -``` - -```elixir -# Fails when amounts don't match -invalid_transaction = %Transaction{charge_amount: 100, refund_amount: 50, status: :refunded} -check_valid_refund.(invalid_transaction) +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 -You can negate `check` directives to test that a projected value does NOT match a condition. - -### negate check with Atom Fields - -```elixir -# Check that name is NOT long -is_long = fn name -> String.length(name) > 5 end - -not_long_name = - pred do - negate check :name, is_long - end - -not_long_name.(alice) -``` - -```elixir -not_long_name.(%User{name: "Joe"}) -``` - -### negate check with Age - -```elixir -# Check user is NOT a senior (< 65) -not_senior = - pred do - negate check :age, fn age -> age >= 65 end - end - -not_senior.(alice) -``` - -### Combining check and negate check - ```elixir -# Adult who is NOT banned +# 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) -``` - -```elixir -valid_user.(bob) -``` - -### negate check with Prism (Reject a Case) - -Use `negate check` with Prism to exclude a specific branch: - -```elixir -# Reject completed orders (only accept pending or cancelled) -not_completed = - pred do - negate check Prism.path([:status, OrderStatus.Completed]), fn _ -> true end - end - -not_completed.(order_pending) # true (not the Completed case) +valid_user.(alice) # true +valid_user.(charlie) # false - not active ``` -```elixir -not_completed.(order_cancelled) # true (not the Completed case) -``` - -```elixir -not_completed.(order_completed) # false (IS the Completed case) -``` - -## `any`: OR Logic +## `any` and `all`: Boolean Logic -The `any` block succeeds if AT LEAST ONE nested predicate passes. - -### Simple any Block +### OR Logic with `any` ```elixir check_admin_or_verified = @@ -567,99 +269,10 @@ check_admin_or_verified = end end -check_admin_or_verified.(alice) -``` - -Admin user passes even though not verified: - -```elixir -check_admin_or_verified.(%User{role: :admin, verified: false}) -``` - -Neither condition passes: - -```elixir -check_admin_or_verified.(%User{role: :user, verified: false}) +Enum.filter(users, check_admin_or_verified) # Alice (admin), Charlie (verified) ``` -### any with Multiple Conditions - -```elixir -check_special_user = - pred do - any do - fn user -> user.role == :admin end - fn user -> user.role == :moderator end - fn user -> user.verified and user.age >= 21 end - end - end - -Enum.filter(users, check_special_user) -``` - -### Mixed Predicates with any - -Active AND (admin OR verified): - -```elixir -check_active_special = - pred do - active? - any do - fn user -> user.role == :admin end - verified? - end - end - -Enum.filter(users, check_active_special) -``` - -## `all`: Explicit AND - -The `all` block makes AND logic explicit (though top-level is already AND): - -```elixir -check_eligible = - pred do - all do - adult? - active? - verified? - end - end - -check_eligible.(alice) -``` - -```elixir -check_eligible.(bob) -``` - -### Combining all and any - -All of (active, verified) AND any of (admin, moderator): - -```elixir -check_staff = - pred do - all do - active? - verified? - end - any do - fn user -> user.role == :admin end - fn user -> user.role == :moderator end - end - end - -Enum.filter(users, check_staff) -``` - -## Deep Nesting - -Blocks can be nested arbitrarily deep for complex logic. - -### Complex Nested Logic +### Nested Blocks Active AND (admin OR (verified AND adult)): @@ -679,69 +292,12 @@ check_complex = Enum.filter(users, check_complex) ``` -### any Containing all Blocks - -(Admin AND verified) OR (moderator AND adult): +## De Morgan's Laws -```elixir -check_senior_staff = - pred do - any do - all do - fn user -> user.role == :admin end - verified? - end - all do - fn user -> user.role == :moderator end - adult? - end - end - end +`negate_all` and `negate_any` apply De Morgan's Laws: -Enum.filter(users, check_senior_staff) -``` - -## Negating Blocks (De Morgan's Laws) - -The `negate_all` and `negate_any` directives apply De Morgan's Laws to negate entire blocks: - -* `negate_all` transforms: NOT (A AND B) → (NOT A) OR (NOT B) -* `negate_any` transforms: NOT (A OR B) → (NOT A) AND (NOT B) - -### negate_all - Reject if all conditions pass - -Use `negate_all` when you want to reject entries where ALL conditions are true: - -```elixir -# Reject premium users (adult AND verified AND vip) -not_premium = - pred do - negate_all do - adult? - verified? - fn user -> user.vip end - end - end - -# Passes if at least one condition fails -not_premium.(%{age: 16, verified: true, vip: true}) # true (not adult) -``` - -```elixir -not_premium.(%{age: 30, verified: false, vip: true}) # true (not verified) -``` - -```elixir -not_premium.(%{age: 30, verified: true, vip: false}) # true (not vip) -``` - -```elixir -not_premium.(%{age: 30, verified: true, vip: true}) # false (all pass) -``` - -### negate_any - Reject if any condition passes - -Use `negate_any` when you want to reject entries where ANY condition is true: +* `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 ```elixir # Regular users only (not vip, not sponsor, not admin) @@ -754,91 +310,13 @@ regular_user = end end -# Passes only if ALL conditions fail -regular_user.(%{vip: false, sponsor: false, role: :user}) -``` - -```elixir -regular_user.(%{vip: true, sponsor: false, role: :user}) # false (vip) -``` - -```elixir -regular_user.(%{vip: false, sponsor: true, role: :user}) # false (sponsor) -``` - -### negate_all with check directives - -```elixir -# Invalid if both age >= 18 AND verified -invalid_user = - pred do - negate_all do - check :age, fn age -> age >= 18 end - check :verified, fn v -> v == true end - end - end - -invalid_user.(%{age: 16, verified: true}) # true (not adult) -``` - -```elixir -invalid_user.(%{age: 30, verified: false}) # true (not verified) -``` - -```elixir -invalid_user.(%{age: 30, verified: true}) # false (both pass) -``` - -### Nesting negate blocks - -You can nest `negate_all` and `negate_any` within other blocks: - -```elixir -# VIP OR not (adult AND verified) -special_or_incomplete = - pred do - any do - fn user -> user.vip end - negate_all do - adult? - verified? - end - end - end - -special_or_incomplete.(%{vip: true, age: 16, verified: false}) # true (vip) -``` - -```elixir -special_or_incomplete.(%{vip: false, age: 16, verified: true}) # true (not adult) -``` - -```elixir -special_or_incomplete.(%{vip: false, age: 30, verified: true}) # false +regular_user.(%{vip: false, sponsor: false, role: :user}) # true +regular_user.(%{vip: true, sponsor: false, role: :user}) # false ``` ## Behaviour Modules -For reusable validation logic, implement the `Funx.Predicate.Dsl.Behaviour`. - -### Simple Behaviour Module - -The `pred/1` callback receives options and returns a predicate function: - -```elixir -defmodule IsActive do - @behaviour Funx.Predicate.Dsl.Behaviour - - @impl true - def pred(_opts) do - fn user -> user.active end - end -end -``` - -### Behaviour with Options - -The options parameter allows runtime configuration: +For reusable, configurable predicates: ```elixir defmodule HasMinimumAge do @@ -850,361 +328,152 @@ defmodule HasMinimumAge do fn user -> user.age >= minimum end end end -``` - -### Using Behaviour Modules - -Reference the behaviour module directly in the DSL: - -```elixir -check_active = - pred do - IsActive - end -check_active.(alice) -``` - -```elixir -check_active.(charlie) -``` - -### Behaviour with Options - -Pass options using tuple syntax: - -```elixir check_21_plus = pred do {HasMinimumAge, minimum: 21} end -check_21_plus.(alice) +check_21_plus.(alice) # true - age 30 +check_21_plus.(%User{age: 19}) # false ``` -```elixir -check_21_plus.(%User{age: 19}) -``` +## Built-in Predicates -### Combining Behaviours with Other Predicates +Funx provides built-in predicate modules: ```elixir -check_active_adult = - pred do - IsActive - {HasMinimumAge, minimum: 18} - end - -Enum.filter(users, check_active_adult) +alias Funx.Predicate.{ + Eq, NotEq, In, NotIn, + LessThan, LessThanOrEqual, GreaterThan, GreaterThanOrEqual, + IsTrue, IsFalse, + MinLength, MaxLength, Pattern, + Integer, Positive, Negative, + Required, Contains +} ``` -## Helper Functions - -Define reusable predicates as 0-arity helper functions: +### Examples ```elixir -defmodule PredicateHelpers do - def adult?, do: fn user -> user.age >= 18 end - def verified?, do: fn user -> user.verified end - def admin?, do: fn user -> user.role == :admin end -end -``` - -### Using Helper Functions - -Reference helper functions in the DSL: - -```elixir -check_verified_adult = +# Equality and membership +check_status = pred do - PredicateHelpers.adult? - PredicateHelpers.verified? + check :status, {Eq, value: :active} + check :role, {In, values: [:admin, :moderator, :user]} end -Enum.filter(users, check_verified_adult) +check_status.(%{status: :active, role: :admin}) # true +check_status.(%{status: :inactive, role: :admin}) # false ``` -## Projection Helpers - -Define reusable projections (optics) as helper functions: - ```elixir -defmodule OpticHelpers do - alias Funx.Optics.{Lens, Prism} - - def age_lens, do: Lens.key(:age) - def name_prism, do: Prism.key(:name) - def role_prism, do: Prism.key(:role) -end -``` - -### Using Projection Helpers with check - -```elixir -check_long_name_helper = +# Comparison predicates +check_adult = pred do - check OpticHelpers.name_prism, fn name -> String.length(name) > 5 end + check :age, {GreaterThanOrEqual, value: 18} end -Enum.filter(users, check_long_name_helper) -``` - -## Real-World Example: Order Filtering - -Define order-related predicates using sum types: - -```elixir -orders = [ - %Order{id: 1, total: 100, status: %OrderStatus.Pending{}, items: 3}, - %Order{id: 2, total: 500, status: %OrderStatus.Completed{total: 500, completed_at: ~U[2024-01-15 10:00:00Z]}, items: 10}, - %Order{id: 3, total: 50, status: %OrderStatus.Cancelled{reason: "Customer request"}, items: 2}, - %Order{id: 4, total: 1000, status: %OrderStatus.Completed{total: 1000, completed_at: ~U[2024-01-20 14:30:00Z]}, items: 15} -] +check_adult.(%{age: 21}) # true +check_adult.(%{age: 16}) # false ``` -### Complex Order Filtering - -Find high-value completed orders or any pending order using Prism for branch selection: - ```elixir -check_important_order = +# String validation +valid_username = pred do - any do - # High-value completed orders (branch selector + predicate) - check Prism.path([:status, OrderStatus.Completed]), fn completed -> - completed.total >= 500 - end - # Any pending order (branch selector only) - check Prism.path([:status, OrderStatus.Pending]), fn _ -> true end - end + check :username, {MinLength, min: 3} + check :username, {MaxLength, max: 20} + check :email, {Pattern, regex: ~r/@/} end -Enum.filter(orders, check_important_order) +valid_username.(%{username: "alice", email: "alice@example.com"}) # true +valid_username.(%{username: "ab", email: "alice@example.com"}) # false ``` -### Using check with Orders - ```elixir -check_bulk_order = +# Numeric predicates +valid_quantity = pred do - check Lens.key(:items), fn items -> items >= 10 end + check :quantity, Integer + check :quantity, Positive end -Enum.filter(orders, check_bulk_order) +valid_quantity.(%{quantity: 5}) # true +valid_quantity.(%{quantity: -1}) # false +valid_quantity.(%{quantity: 5.5}) # false ``` -## Edge Cases +### Default Truthy vs Required -### Empty pred Block - -An empty `pred` block returns a predicate that always returns `true`: +When `check` has no predicate, it defaults to truthy (`!!value`): ```elixir -check_any = +truthy_check = pred do + check :name # equivalent to: fn v -> !!v end end -check_any.(alice) -``` - -```elixir -check_any.(bob) -``` - -This is useful as a default or for composing predicates dynamically. - -### Single Predicate in any Block - -```elixir -check_single_any = +required_check = pred do - any do - adult? - end + check :name, Required # not nil AND not "" end -check_single_any.(alice) -``` - -### Single Predicate in all Block +# Empty string is truthy but not Required +truthy_check.(%{name: ""}) # true +required_check.(%{name: ""}) # false -```elixir -check_single_all = - pred do - all do - adult? - end - end - -check_single_all.(alice) -``` - -## Pattern: Find First Match - -Use predicates with `Enum.find/2`: - -```elixir -first_admin = Enum.find(users, fn user -> user.role == :admin end) -``` - -Or with the DSL: - -```elixir -check_admin = - pred do - fn user -> user.role == :admin end - end - -Enum.find(users, check_admin) +# false is falsy but passes Required (it's a present value) +truthy_check.(%{name: false}) # false +required_check.(%{name: false}) # true ``` -## Pattern: Partition by Condition - -Use predicates with `Enum.split_with/2`: - -```elixir -{adults, minors} = Enum.split_with(users, check_adult) - -IO.inspect(adults, label: "Adults") -IO.inspect(minors, label: "Minors") -``` +| 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 | -## Pattern: Count Matching Items +## Integration with Enum ```elixir -active_users = +check_eligible = pred do - active? + check :age, {GreaterThanOrEqual, value: 18} + check :verified, IsTrue end -Enum.count(users, active_users) -``` - -## Pattern: Check if Any/All Match - -```elixir -# Any user is an admin -Enum.any?(users, fn user -> user.role == :admin end) -``` - -```elixir -# All users are active -Enum.all?(users, active?) -``` - -## Compile-Time Validation - -The DSL validates at compile time to catch errors early with helpful messages. - -### Invalid Projection Types - -Only specific projection types are allowed in `check` directives: - -```elixir -pred do - check "invalid_string", fn _ -> true end -end -``` - -Valid projection types: - -* Atoms: `:field_name` -* Lens: `Lens.key(:name)` -* Prism: `Prism.struct(User)` -* Traversal: `Traversal.combine([...])` -* Functions: `&(&1.field)`, `fn x -> x.value end` -* Variables: `my_lens` (bound at runtime) -* Module calls: `MyModule.my_lens()` - -### Bare Module Without Behaviour - -Bare module references must implement `Predicate.Dsl.Behaviour`: - -```elixir -defmodule NotABehaviour do - def some_function, do: :ok -end - -pred do - NotABehaviour -end -``` - -Fix by implementing the behaviour or calling a function: - -```elixir -defmodule IsBanned do - @behaviour Funx.Predicate.Dsl.Behaviour - - def pred(_opts) do - fn user -> user.banned end - end -end - -# Option 2: Call a function -pred do - NotABehaviour.some_function() -end -``` - -### negate Without Predicate - -Using `negate` without a predicate raises an error: - -```elixir -pred do - negate -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 -Three mistakes to avoid: - * **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 a declarative, composable way to build complex boolean filters: +The Predicate DSL provides declarative, composable boolean filters: * Bare predicates for simple function composition - * `negate` for logical negation - -* `check` directive for projection-based predicates - -* **Lenses** for total field access (always succeeds) - -* **Prisms** for branch selection on sum types (selects one case, excludes others) - -* **Traversals** for focusing multiple elements - +* `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 -* Functions for custom transformations - -* Behaviours for reusable validation logic - -* `any` blocks for OR logic - -* `all` blocks for explicit AND logic - -* Works seamlessly with `Enum.filter`, `Enum.find`, and other predicate-accepting functions - -**Key Insight:** A Prism does not extract a field—it selects a case. Predicates composed with Prisms only apply when the value matches that case, making them ideal for domain boundaries and structural variants. - -All predicates compose with AND logic at the top level, while `any` blocks provide OR logic. This makes it easy to express complex filtering rules like "pass if (A and B) or (C and D)" without nested conditionals. - -The DSL integrates naturally with Elixir's Enum module, making it ideal for filtering collections, finding elements, and partitioning data based on complex boolean conditions. - -## When to Reach for This - -Use `pred` when: - -* You want **reusable boolean logic** that can be named and composed -* You need to **separate structure from logic** (projection from predicate) -* You want to **name domain rules** explicitly ("adult and verified", "high-value order") -* You need **composable OR/AND logic** without nested conditionals -* You're filtering/finding in collections and want **declarative rules** instead of procedural loops +Works seamlessly with `Enum.filter`, `Enum.find`, and other predicate-accepting functions. diff --git a/test/predicate/comparison_test.exs b/test/predicate/comparison_test.exs new file mode 100644 index 00000000..310cbb14 --- /dev/null +++ b/test/predicate/comparison_test.exs @@ -0,0 +1,160 @@ +defmodule Funx.Predicate.ComparisonTest do + use ExUnit.Case, async: true + use Funx.Predicate + + alias Funx.Predicate.{GreaterThan, GreaterThanOrEqual, LessThan, LessThanOrEqual} + + doctest GreaterThan + doctest GreaterThanOrEqual + doctest LessThan + doctest LessThanOrEqual + + describe "LessThan predicate" do + test "returns true when value is less than reference" do + predicate = LessThan.pred(value: 10) + + assert predicate.(5) + assert predicate.(9) + refute predicate.(10) + refute predicate.(11) + end + + test "works with strings" do + predicate = LessThan.pred(value: "b") + + assert predicate.("a") + refute predicate.("b") + refute predicate.("c") + end + + test "in DSL with check" do + under_limit = + pred do + check :score, {LessThan, value: 100} + end + + assert under_limit.(%{score: 50}) + assert under_limit.(%{score: 99}) + refute under_limit.(%{score: 100}) + refute under_limit.(%{score: 150}) + end + end + + describe "LessThanOrEqual predicate" do + test "returns true when value is less than or equal to reference" do + predicate = LessThanOrEqual.pred(value: 10) + + assert predicate.(5) + assert predicate.(10) + refute predicate.(11) + end + + test "works with strings" do + predicate = LessThanOrEqual.pred(value: "b") + + assert predicate.("a") + assert predicate.("b") + refute predicate.("c") + end + + test "in DSL with check" do + at_most = + pred do + check :score, {LessThanOrEqual, value: 100} + end + + assert at_most.(%{score: 50}) + assert at_most.(%{score: 100}) + refute at_most.(%{score: 101}) + end + end + + describe "GreaterThan predicate" do + test "returns true when value is greater than reference" do + predicate = GreaterThan.pred(value: 10) + + assert predicate.(15) + assert predicate.(11) + refute predicate.(10) + refute predicate.(5) + end + + test "works with strings" do + predicate = GreaterThan.pred(value: "b") + + assert predicate.("c") + refute predicate.("b") + refute predicate.("a") + end + + test "in DSL with check" do + over_limit = + pred do + check :score, {GreaterThan, value: 0} + end + + assert over_limit.(%{score: 1}) + assert over_limit.(%{score: 100}) + refute over_limit.(%{score: 0}) + refute over_limit.(%{score: -5}) + end + end + + describe "GreaterThanOrEqual predicate" do + test "returns true when value is greater than or equal to reference" do + predicate = GreaterThanOrEqual.pred(value: 10) + + assert predicate.(15) + assert predicate.(10) + refute predicate.(9) + end + + test "works with strings" do + predicate = GreaterThanOrEqual.pred(value: "b") + + assert predicate.("c") + assert predicate.("b") + refute predicate.("a") + end + + test "in DSL with check" do + at_least = + pred do + check :score, {GreaterThanOrEqual, value: 0} + end + + assert at_least.(%{score: 0}) + assert at_least.(%{score: 100}) + refute at_least.(%{score: -1}) + end + end + + describe "combining comparison predicates" do + test "range check with Gte and Lt" do + in_range = + pred do + check :value, {GreaterThanOrEqual, value: 0} + check :value, {LessThan, value: 100} + end + + assert in_range.(%{value: 0}) + assert in_range.(%{value: 50}) + assert in_range.(%{value: 99}) + refute in_range.(%{value: -1}) + refute in_range.(%{value: 100}) + end + + test "exclusive range with Gt and Lt" do + in_range = + pred do + check :value, {GreaterThan, value: 0} + check :value, {LessThan, value: 100} + end + + refute in_range.(%{value: 0}) + assert in_range.(%{value: 1}) + assert in_range.(%{value: 99}) + refute in_range.(%{value: 100}) + end + end +end diff --git a/test/predicate/contains_test.exs b/test/predicate/contains_test.exs new file mode 100644 index 00000000..8b91206d --- /dev/null +++ b/test/predicate/contains_test.exs @@ -0,0 +1,100 @@ +defmodule Funx.Predicate.ContainsTest do + use ExUnit.Case, async: true + use Funx.Predicate + + doctest Funx.Predicate.Contains + + alias Funx.Predicate.Contains + + describe "Contains predicate standalone" do + test "returns true when list contains element" do + predicate = Contains.pred(value: :poison_resistance) + + assert predicate.([:fire_resistance, :poison_resistance, :cold_resistance]) + assert predicate.([:poison_resistance]) + refute predicate.([:fire_resistance, :cold_resistance]) + refute predicate.([]) + end + + test "works with strings" do + predicate = Contains.pred(value: "featured") + + assert predicate.(["new", "featured", "sale"]) + refute predicate.(["new", "sale"]) + end + + test "works with integers" do + predicate = Contains.pred(value: 42) + + assert predicate.([1, 42, 100]) + refute predicate.([1, 2, 3]) + end + + test "returns false for non-list values" do + predicate = Contains.pred(value: :foo) + + refute predicate.(:foo) + refute predicate.("foo") + refute predicate.(%{foo: :bar}) + refute predicate.(nil) + end + end + + describe "Contains predicate in DSL" do + test "check with Contains using tuple syntax" do + has_resistance = + pred do + check :grants, {Contains, value: :poison_resistance} + end + + assert has_resistance.(%{grants: [:poison_resistance, :fire_resistance]}) + assert has_resistance.(%{grants: [:poison_resistance]}) + refute has_resistance.(%{grants: [:fire_resistance]}) + refute has_resistance.(%{grants: []}) + refute has_resistance.(%{}) + end + + test "check with nested path" do + poison_resistant = + pred do + check [:blessing, :grants], {Contains, value: :poison_resistance} + end + + assert poison_resistant.(%{blessing: %{grants: [:poison_resistance]}}) + refute poison_resistant.(%{blessing: %{grants: []}}) + refute poison_resistant.(%{blessing: %{}}) + refute poison_resistant.(%{}) + end + + test "negate check with Contains" do + no_admin = + pred do + negate check :roles, {Contains, value: :admin} + end + + assert no_admin.(%{roles: [:user, :guest]}) + assert no_admin.(%{roles: []}) + refute no_admin.(%{roles: [:admin, :user]}) + end + + test "combined with other predicates" do + valid_user = + pred do + check :active + check :permissions, {Contains, value: :read} + end + + assert valid_user.(%{active: true, permissions: [:read, :write]}) + refute valid_user.(%{active: false, permissions: [:read, :write]}) + refute valid_user.(%{active: true, permissions: [:write]}) + end + end + + describe "Contains predicate argument validation" do + test "raises when :value option is missing" do + assert_raise KeyError, fn -> + Contains.pred([]) + end + end + end +end diff --git a/test/predicate/dsl/predicate_dsl_test.exs b/test/predicate/dsl/predicate_dsl_test.exs index 69355051..8553d43a 100644 --- a/test/predicate/dsl/predicate_dsl_test.exs +++ b/test/predicate/dsl/predicate_dsl_test.exs @@ -211,7 +211,7 @@ defmodule Funx.Predicate.DslTest do test "check with atom field", %{age_20: age_20, age_16: age_16, age_empty: age_empty} do age_check = pred do - check(:age, fn age -> age >= 18 end) + check :age, fn age -> age >= 18 end end assert age_check.(age_20) @@ -226,7 +226,7 @@ defmodule Funx.Predicate.DslTest do } do name_length_check = pred do - check(Prism.key(:name), fn name -> String.length(name) > 3 end) + check Prism.key(:name), fn name -> String.length(name) > 3 end end assert name_length_check.(has_name) @@ -239,9 +239,9 @@ defmodule Funx.Predicate.DslTest do # Only applies to Completed orders (branch selection) check_high_value_completed = pred do - check(Prism.path([:status, OrderStatus.Completed]), fn completed -> + check Prism.path([:status, OrderStatus.Completed]), fn completed -> completed.total >= 500 - end) + end end completed_order = %Order{ @@ -263,7 +263,7 @@ defmodule Funx.Predicate.DslTest do test "check with Lens.key", %{high_score: high_score, low_score: low_score} do score_check = pred do - check(Lens.key(:score), fn score -> score > 100 end) + check Lens.key(:score), fn score -> score > 100 end end assert score_check.(high_score) @@ -273,7 +273,7 @@ defmodule Funx.Predicate.DslTest do test "on with captured function projection" do check = pred do - check(&Map.get(&1, :age), fn age -> age >= 21 end) + check &Map.get(&1, :age), fn age -> age >= 21 end end assert check.(%{age: 25}) @@ -283,7 +283,7 @@ defmodule Funx.Predicate.DslTest do test "on with anonymous function projection" do check = pred do - check(fn person -> person.age end, fn age -> age >= 18 end) + check fn person -> person.age end, fn age -> age >= 18 end end assert check.(%{age: 20}) @@ -296,7 +296,7 @@ defmodule Funx.Predicate.DslTest do check = pred do - check(my_lens, fn score -> score > 100 end) + check my_lens, fn score -> score > 100 end end assert check.(%{score: 150}) @@ -342,8 +342,8 @@ defmodule Funx.Predicate.DslTest do test "on with multiple projections" do check = pred do - check(:age, fn age -> age >= 18 end) - check(:score, fn score -> score > 50 end) + check :age, fn age -> age >= 18 end + check :score, fn score -> score > 50 end end assert check.(%{age: 20, score: 75}) @@ -358,7 +358,7 @@ defmodule Funx.Predicate.DslTest do age_check = pred do - check([:user, :profile, :age], fn age -> age >= 18 end) + check [:user, :profile, :age], fn age -> age >= 18 end end assert age_check.(nested_data) @@ -374,7 +374,7 @@ defmodule Funx.Predicate.DslTest do name_check = pred do - check([User, :name], fn name -> String.length(name) > 3 end) + check [User, :name], fn name -> String.length(name) > 3 end end # Works with struct projection @@ -383,7 +383,7 @@ defmodule Funx.Predicate.DslTest do # Works with nested struct nested_name_check = pred do - check([:user, User, :name], fn name -> String.length(name) > 3 end) + check [:user, User, :name], fn name -> String.length(name) > 3 end end assert nested_name_check.(nested_data) @@ -396,13 +396,85 @@ defmodule Funx.Predicate.DslTest do name_check = pred do - check([:user, :profile, :name], fn name -> String.length(name) > 3 end) + check [:user, :profile, :name], fn name -> String.length(name) > 3 end end refute name_check.(nested_data) end end + # ============================================================================ + # Default Truthy Check Tests + # ============================================================================ + + describe "check with default truthy predicate" do + test "single-argument check defaults to truthy" do + is_active = + pred do + check :active + end + + assert is_active.(%{active: true}) + assert is_active.(%{active: 1}) + assert is_active.(%{active: "yes"}) + refute is_active.(%{active: false}) + refute is_active.(%{active: nil}) + refute is_active.(%{}) + end + + test "single-argument check with nested path" do + is_poisoned = + pred do + check [:poison, :active] + end + + assert is_poisoned.(%{poison: %{active: true}}) + assert is_poisoned.(%{poison: %{active: 1}}) + refute is_poisoned.(%{poison: %{active: false}}) + refute is_poisoned.(%{poison: %{active: nil}}) + refute is_poisoned.(%{poison: %{}}) + refute is_poisoned.(%{}) + end + + test "negate single-argument check" do + not_active = + pred do + negate check :active + end + + assert not_active.(%{active: false}) + assert not_active.(%{active: nil}) + assert not_active.(%{}) + refute not_active.(%{active: true}) + refute not_active.(%{active: 1}) + end + + test "multiple single-argument checks" do + all_flags = + pred do + check :active + check :verified + check :approved + end + + assert all_flags.(%{active: true, verified: true, approved: true}) + refute all_flags.(%{active: true, verified: false, approved: true}) + refute all_flags.(%{active: true, verified: true, approved: nil}) + end + + test "mixed single and two-argument checks" do + valid_user = + pred do + check :active + check :age, fn age -> age >= 18 end + end + + assert valid_user.(%{active: true, age: 20}) + refute valid_user.(%{active: false, age: 20}) + refute valid_user.(%{active: true, age: 16}) + end + end + # ============================================================================ # Helper Function Tests # ============================================================================ @@ -411,7 +483,7 @@ defmodule Funx.Predicate.DslTest do test "0-arity helper returning Prism" do check = pred do - check(OpticHelpers.name_prism(), fn name -> String.length(name) > 5 end) + check OpticHelpers.name_prism(), fn name -> String.length(name) > 5 end end assert check.(%{name: "Alexander"}) @@ -421,7 +493,7 @@ defmodule Funx.Predicate.DslTest do test "0-arity helper returning Lens" do check = pred do - check(OpticHelpers.age_lens(), fn age -> age >= 21 end) + check OpticHelpers.age_lens(), fn age -> age >= 21 end end assert check.(%{age: 25}) @@ -431,12 +503,12 @@ defmodule Funx.Predicate.DslTest do test "0-arity helper returning Traversal (relating foci)" do check = pred do - check(OpticHelpers.amounts_traversal(), fn amounts -> + check OpticHelpers.amounts_traversal(), fn amounts -> case amounts do [charge, refund] -> charge == refund _ -> false end - end) + end end assert check.(%Transaction{charge_amount: 100, refund_amount: 100, status: :refunded}) @@ -491,7 +563,7 @@ defmodule Funx.Predicate.DslTest do check = pred do IsActive - check(:age, fn age -> age >= 18 end) + check :age, fn age -> age >= 18 end end assert check.(%User{active: true, age: 20}) @@ -660,7 +732,7 @@ defmodule Funx.Predicate.DslTest do test "negate check with atom field (degenerate sum)" do not_long_name = pred do - negate(check(:name, fn name -> String.length(name) > 5 end)) + negate check :name, fn name -> String.length(name) > 5 end end assert not_long_name.(%{name: "Joe"}) @@ -672,7 +744,7 @@ defmodule Funx.Predicate.DslTest do test "negate check with Prism.key (degenerate sum)" do not_adult = pred do - negate(check(Prism.key(:age), fn age -> age >= 18 end)) + negate check Prism.key(:age), fn age -> age >= 18 end end assert not_adult.(%{age: 16}) @@ -684,7 +756,7 @@ defmodule Funx.Predicate.DslTest do test "negate check with Lens.key" do low_score = pred do - negate(check(Lens.key(:score), fn score -> score > 100 end)) + negate check Lens.key(:score), fn score -> score > 100 end end assert low_score.(%{score: 50}) @@ -694,7 +766,7 @@ defmodule Funx.Predicate.DslTest do test "negate check with function projection" do not_verified = pred do - negate(check(fn user -> user.verified end, fn v -> v == true end)) + negate check fn user -> user.verified end, fn v -> v == true end end assert not_verified.(%{verified: false}) @@ -735,8 +807,8 @@ defmodule Funx.Predicate.DslTest do test "multiple negate check directives" do safe_user = pred do - negate(check(:banned, fn b -> b == true end)) - negate(check(:suspended, fn s -> s == true end)) + negate check :banned, fn b -> b == true end + negate check :suspended, fn s -> s == true end end assert safe_user.(%{banned: false, suspended: false}) @@ -747,8 +819,8 @@ defmodule Funx.Predicate.DslTest do test "mixed check and negate check" do valid_user = pred do - check(:age, fn age -> age >= 18 end) - negate(check(:banned, fn b -> b == true end)) + check :age, fn age -> age >= 18 end + negate check :banned, fn b -> b == true end end assert valid_user.(%{age: 20, banned: false}) @@ -760,7 +832,7 @@ defmodule Funx.Predicate.DslTest do can_enter = pred do &adult?/1 - negate(check(:banned, fn b -> b == true end)) + negate check :banned, fn b -> b == true end end assert can_enter.(%{age: 20, tickets: 1, banned: false}) @@ -773,7 +845,7 @@ defmodule Funx.Predicate.DslTest do pred do any do &vip?/1 - negate(check(:suspended, fn s -> s == true end)) + negate check :suspended, fn s -> s == true end end end @@ -789,8 +861,8 @@ defmodule Funx.Predicate.DslTest do verified_user = pred do all do - check(:age, fn age -> age >= 18 end) - negate(check(:banned, fn b -> b == true end)) + check :age, fn age -> age >= 18 end + negate check :banned, fn b -> b == true end end end @@ -806,7 +878,7 @@ defmodule Funx.Predicate.DslTest do not_adult = pred do - negate(check([:user, :profile, :age], fn age -> age >= 18 end)) + negate check [:user, :profile, :age], fn age -> age >= 18 end end refute not_adult.(nested_adult) diff --git a/test/predicate/eq_test.exs b/test/predicate/eq_test.exs new file mode 100644 index 00000000..35812313 --- /dev/null +++ b/test/predicate/eq_test.exs @@ -0,0 +1,148 @@ +defmodule Funx.Predicate.EqTest do + use ExUnit.Case, async: true + use Funx.Predicate + + alias Funx.Predicate.Eq + + defp case_insensitive_eq do + %{ + eq?: fn a, b when is_binary(a) and is_binary(b) -> + String.downcase(a) == String.downcase(b) + end, + not_eq?: fn a, b when is_binary(a) and is_binary(b) -> + String.downcase(a) != String.downcase(b) + end + } + end + + describe "Eq predicate standalone" do + test "returns true for equal values" do + predicate = Eq.pred(value: 5) + + assert predicate.(5) + refute predicate.(6) + end + + test "works with strings" do + predicate = Eq.pred(value: "hello") + + assert predicate.("hello") + refute predicate.("world") + end + + test "works with atoms" do + predicate = Eq.pred(value: :active) + + assert predicate.(:active) + refute predicate.(:inactive) + end + + test "works with custom Eq comparator" do + predicate = Eq.pred(value: "hello", eq: case_insensitive_eq()) + + assert predicate.("HELLO") + assert predicate.("Hello") + refute predicate.("world") + end + end + + describe "Eq predicate with struct module equality" do + defmodule Purchase do + defstruct [:id] + end + + defmodule Refund do + defstruct [:id] + end + + test "passes when value is a struct and expected is its module" do + predicate = Eq.pred(value: Purchase) + + assert predicate.(%Purchase{id: 1}) + refute predicate.(%Refund{id: 1}) + end + + test "fails when expected is a module but value is not a struct" do + predicate = Eq.pred(value: Purchase) + + refute predicate.("purchase") + refute predicate.(:purchase) + end + end + + describe "Eq predicate in DSL" do + test "check with Eq using tuple syntax" do + is_active = + pred do + check :status, {Eq, value: :active} + end + + assert is_active.(%{status: :active}) + refute is_active.(%{status: :inactive}) + end + + test "check with Eq for string value" do + is_admin = + pred do + check :role, {Eq, value: "admin"} + end + + assert is_admin.(%{role: "admin"}) + refute is_admin.(%{role: "user"}) + end + + test "check with Eq using custom comparator" do + eq_opts = case_insensitive_eq() + + matches_name = + pred do + check :name, {Eq, value: "alice", eq: eq_opts} + end + + assert matches_name.(%{name: "ALICE"}) + assert matches_name.(%{name: "Alice"}) + refute matches_name.(%{name: "Bob"}) + end + + test "check with nested path" do + is_completed = + pred do + check [:order, :status], {Eq, value: :completed} + end + + assert is_completed.(%{order: %{status: :completed}}) + refute is_completed.(%{order: %{status: :pending}}) + refute is_completed.(%{}) + end + + test "negate check with Eq" do + not_banned = + pred do + negate check :status, {Eq, value: :banned} + end + + assert not_banned.(%{status: :active}) + refute not_banned.(%{status: :banned}) + end + + test "combined with other predicates" do + valid_user = + pred do + check :status, {Eq, value: :active} + check :age, fn age -> age >= 18 end + end + + assert valid_user.(%{status: :active, age: 20}) + refute valid_user.(%{status: :inactive, age: 20}) + refute valid_user.(%{status: :active, age: 16}) + end + end + + describe "Eq predicate argument validation" do + test "raises when :value option is missing" do + assert_raise KeyError, fn -> + Eq.pred([]) + end + end + end +end diff --git a/test/predicate/false_test.exs b/test/predicate/false_test.exs new file mode 100644 index 00000000..0ff0c1de --- /dev/null +++ b/test/predicate/false_test.exs @@ -0,0 +1,125 @@ +defmodule Funx.Predicate.IsFalseTest do + use ExUnit.Case, async: true + use Funx.Predicate + + alias Funx.Predicate.IsFalse + + describe "IsFalse predicate standalone" do + test "returns true for false value" do + predicate = IsFalse.pred() + + assert predicate.(false) + end + + test "returns false for true value" do + predicate = IsFalse.pred() + + refute predicate.(true) + end + + test "returns false for falsy values (strict equality)" do + predicate = IsFalse.pred() + + refute predicate.(nil) + refute predicate.(0) + refute predicate.("") + end + + test "returns false for truthy values" do + predicate = IsFalse.pred() + + refute predicate.(1) + refute predicate.("false") + refute predicate.(:false_atom) + end + end + + describe "IsFalse predicate in DSL" do + test "check with IsFalse for boolean flag" do + is_staunched = + pred do + check :staunched, IsFalse + end + + assert is_staunched.(%{staunched: false}) + refute is_staunched.(%{staunched: true}) + refute is_staunched.(%{}) + end + + test "check with IsFalse using tuple syntax" do + is_staunched = + pred do + check :staunched, {IsFalse, []} + end + + assert is_staunched.(%{staunched: false}) + refute is_staunched.(%{staunched: true}) + end + + test "check with nested path" do + bleeding = + pred do + check [:bleeding, :staunched], IsFalse + end + + assert bleeding.(%{bleeding: %{staunched: false}}) + refute bleeding.(%{bleeding: %{staunched: true}}) + refute bleeding.(%{bleeding: %{}}) + refute bleeding.(%{}) + end + + test "negate check with IsFalse" do + is_staunched = + pred do + negate check :staunched, IsFalse + end + + assert is_staunched.(%{staunched: true}) + assert is_staunched.(%{}) + refute is_staunched.(%{staunched: false}) + end + + test "multiple IsFalse checks" do + no_flags_set = + pred do + check :banned, IsFalse + check :suspended, IsFalse + check :deleted, IsFalse + end + + assert no_flags_set.(%{banned: false, suspended: false, deleted: false}) + refute no_flags_set.(%{banned: true, suspended: false, deleted: false}) + refute no_flags_set.(%{banned: false, suspended: true, deleted: false}) + end + + test "combined with IsTrue predicate" do + alias Funx.Predicate.IsTrue + + active_not_banned = + pred do + check :active, IsTrue + check :banned, IsFalse + end + + assert active_not_banned.(%{active: true, banned: false}) + refute active_not_banned.(%{active: false, banned: false}) + refute active_not_banned.(%{active: true, banned: true}) + end + + test "within any block" do + alias Funx.Predicate.IsTrue + + safe_to_proceed = + pred do + any do + check :override, IsTrue + check :blocked, IsFalse + end + end + + assert safe_to_proceed.(%{override: true, blocked: true}) + assert safe_to_proceed.(%{override: false, blocked: false}) + refute safe_to_proceed.(%{override: false, blocked: true}) + end + end +end diff --git a/test/predicate/in_test.exs b/test/predicate/in_test.exs new file mode 100644 index 00000000..e78d4dc1 --- /dev/null +++ b/test/predicate/in_test.exs @@ -0,0 +1,114 @@ +defmodule Funx.Predicate.InTest do + use ExUnit.Case, async: true + use Funx.Predicate + + alias Funx.Predicate.In + + describe "In predicate standalone" do + test "returns true when value is in list" do + predicate = In.pred(values: [:active, :pending, :completed]) + + assert predicate.(:active) + assert predicate.(:pending) + assert predicate.(:completed) + refute predicate.(:cancelled) + refute predicate.(:unknown) + end + + test "works with strings" do + predicate = In.pred(values: ["red", "green", "blue"]) + + assert predicate.("red") + assert predicate.("green") + refute predicate.("yellow") + end + + test "works with integers" do + predicate = In.pred(values: [1, 2, 3]) + + assert predicate.(1) + assert predicate.(2) + refute predicate.(4) + end + end + + describe "In predicate with struct modules" do + defmodule Click do + defstruct [:x, :y] + end + + defmodule Scroll do + defstruct [:delta] + end + + defmodule Submit do + defstruct [:form_id] + end + + test "matches struct by module" do + predicate = In.pred(values: [Click, Scroll]) + + assert predicate.(%Click{x: 10, y: 20}) + assert predicate.(%Scroll{delta: 5}) + refute predicate.(%Submit{form_id: "form1"}) + end + end + + describe "In predicate in DSL" do + test "check with In using tuple syntax" do + valid_status = + pred do + check :status, {In, values: [:active, :pending]} + end + + assert valid_status.(%{status: :active}) + assert valid_status.(%{status: :pending}) + refute valid_status.(%{status: :cancelled}) + refute valid_status.(%{}) + end + + test "check with nested path" do + valid_exposure = + pred do + check [:exposure, :water], {In, values: [:dry, :wet, :soaked]} + end + + assert valid_exposure.(%{exposure: %{water: :wet}}) + assert valid_exposure.(%{exposure: %{water: :soaked}}) + refute valid_exposure.(%{exposure: %{water: :drenched}}) + refute valid_exposure.(%{}) + end + + test "negate check with In" do + not_special = + pred do + negate check :role, {In, values: [:admin, :moderator]} + end + + assert not_special.(%{role: :user}) + assert not_special.(%{role: :guest}) + refute not_special.(%{role: :admin}) + refute not_special.(%{role: :moderator}) + end + + test "combined with other predicates" do + valid_user = + pred do + check :status, {In, values: [:active, :pending]} + check :verified + end + + assert valid_user.(%{status: :active, verified: true}) + refute valid_user.(%{status: :cancelled, verified: true}) + refute valid_user.(%{status: :active, verified: false}) + end + end + + describe "In predicate argument validation" do + test "raises when :values option is missing" do + assert_raise KeyError, fn -> + In.pred([]) + end + end + end +end diff --git a/test/predicate/integer_test.exs b/test/predicate/integer_test.exs new file mode 100644 index 00000000..de89cdb0 --- /dev/null +++ b/test/predicate/integer_test.exs @@ -0,0 +1,74 @@ +defmodule Funx.Predicate.IntegerTest do + use ExUnit.Case, async: true + use Funx.Predicate + + alias Funx.Predicate.{GreaterThan, Integer} + + describe "Integer predicate standalone" do + test "returns true for integers" do + predicate = Integer.pred() + + assert predicate.(0) + assert predicate.(1) + assert predicate.(-1) + assert predicate.(1_000_000) + end + + test "returns false for floats" do + predicate = Integer.pred() + + refute predicate.(1.0) + refute predicate.(0.5) + refute predicate.(-3.14) + end + + test "returns false for non-numbers" do + predicate = Integer.pred() + + refute predicate.("5") + refute predicate.(:five) + refute predicate.(nil) + refute predicate.([1, 2, 3]) + end + end + + describe "Integer predicate in DSL" do + test "check with Integer" do + is_integer_count = + pred do + check :count, Integer + end + + assert is_integer_count.(%{count: 5}) + assert is_integer_count.(%{count: 0}) + assert is_integer_count.(%{count: -10}) + refute is_integer_count.(%{count: 5.5}) + refute is_integer_count.(%{count: "5"}) + refute is_integer_count.(%{}) + end + + test "negate check with Integer" do + not_integer = + pred do + negate check :value, Integer + end + + assert not_integer.(%{value: 5.5}) + assert not_integer.(%{value: "hello"}) + refute not_integer.(%{value: 5}) + end + + test "combined with other predicates" do + positive_integer = + pred do + check :count, Integer + check :count, {GreaterThan, value: 0} + end + + assert positive_integer.(%{count: 5}) + refute positive_integer.(%{count: 0}) + refute positive_integer.(%{count: -5}) + refute positive_integer.(%{count: 5.5}) + end + end +end diff --git a/test/predicate/length_test.exs b/test/predicate/length_test.exs new file mode 100644 index 00000000..8b2ab598 --- /dev/null +++ b/test/predicate/length_test.exs @@ -0,0 +1,108 @@ +defmodule Funx.Predicate.LengthTest do + use ExUnit.Case, async: true + use Funx.Predicate + + alias Funx.Predicate.{MaxLength, MinLength} + + describe "MinLength predicate standalone" do + test "returns true when string meets minimum length" do + predicate = MinLength.pred(min: 3) + + assert predicate.("hello") + assert predicate.("abc") + refute predicate.("ab") + refute predicate.("") + end + + test "returns false for non-strings" do + predicate = MinLength.pred(min: 1) + + refute predicate.(123) + refute predicate.(nil) + refute predicate.([:a, :b]) + end + end + + describe "MaxLength predicate standalone" do + test "returns true when string does not exceed maximum length" do + predicate = MaxLength.pred(max: 5) + + assert predicate.("hello") + assert predicate.("hi") + assert predicate.("") + refute predicate.("hello world") + end + + test "returns false for non-strings" do + predicate = MaxLength.pred(max: 10) + + refute predicate.(123) + refute predicate.(nil) + refute predicate.([:a, :b]) + end + end + + describe "MinLength predicate in DSL" do + test "check with MinLength" do + long_enough = + pred do + check :name, {MinLength, min: 2} + end + + assert long_enough.(%{name: "Joe"}) + assert long_enough.(%{name: "Al"}) + refute long_enough.(%{name: "J"}) + refute long_enough.(%{name: ""}) + refute long_enough.(%{}) + end + + test "negate check with MinLength" do + too_short = + pred do + negate check :name, {MinLength, min: 5} + end + + assert too_short.(%{name: "Joe"}) + refute too_short.(%{name: "Joseph"}) + end + end + + describe "MaxLength predicate in DSL" do + test "check with MaxLength" do + short_enough = + pred do + check :code, {MaxLength, max: 10} + end + + assert short_enough.(%{code: "ABC123"}) + assert short_enough.(%{code: ""}) + refute short_enough.(%{code: "ABCDEFGHIJK"}) + refute short_enough.(%{}) + end + + test "negate check with MaxLength" do + too_long = + pred do + negate check :name, {MaxLength, max: 3} + end + + assert too_long.(%{name: "Joseph"}) + refute too_long.(%{name: "Joe"}) + end + end + + describe "combined length predicates" do + test "min and max length together" do + valid_length = + pred do + check :password, {MinLength, min: 8} + check :password, {MaxLength, max: 128} + end + + assert valid_length.(%{password: "secure123"}) + assert valid_length.(%{password: String.duplicate("a", 128)}) + refute valid_length.(%{password: "short"}) + refute valid_length.(%{password: String.duplicate("a", 129)}) + end + end +end diff --git a/test/predicate/negative_test.exs b/test/predicate/negative_test.exs new file mode 100644 index 00000000..8217ba37 --- /dev/null +++ b/test/predicate/negative_test.exs @@ -0,0 +1,80 @@ +defmodule Funx.Predicate.NegativeTest do + use ExUnit.Case, async: true + use Funx.Predicate + + alias Funx.Predicate.{Integer, Negative} + + describe "Negative predicate standalone" do + test "returns true for negative numbers" do + predicate = Negative.pred() + + assert predicate.(-1) + assert predicate.(-0.1) + assert predicate.(-1_000_000) + assert predicate.(-0.0001) + end + + test "returns false for zero" do + predicate = Negative.pred() + + refute predicate.(0) + refute predicate.(0.0) + end + + test "returns false for positive numbers" do + predicate = Negative.pred() + + refute predicate.(1) + refute predicate.(0.1) + refute predicate.(1_000_000) + end + + test "returns false for non-numbers" do + predicate = Negative.pred() + + refute predicate.("-5") + refute predicate.(:negative) + refute predicate.(nil) + refute predicate.([-1, -2, -3]) + end + end + + describe "Negative predicate in DSL" do + test "check with Negative" do + negative_balance = + pred do + check :balance, Negative + end + + assert negative_balance.(%{balance: -100}) + assert negative_balance.(%{balance: -0.01}) + refute negative_balance.(%{balance: 0}) + refute negative_balance.(%{balance: 50}) + refute negative_balance.(%{}) + end + + test "negate check with Negative" do + not_negative = + pred do + negate check :value, Negative + end + + assert not_negative.(%{value: 0}) + assert not_negative.(%{value: 5}) + refute not_negative.(%{value: -5}) + end + + test "combined with Integer" do + negative_integer = + pred do + check :adjustment, Integer + check :adjustment, Negative + end + + assert negative_integer.(%{adjustment: -5}) + refute negative_integer.(%{adjustment: -5.5}) + refute negative_integer.(%{adjustment: 0}) + refute negative_integer.(%{adjustment: 5}) + end + end +end diff --git a/test/predicate/not_eq_test.exs b/test/predicate/not_eq_test.exs new file mode 100644 index 00000000..d4aa89de --- /dev/null +++ b/test/predicate/not_eq_test.exs @@ -0,0 +1,81 @@ +defmodule Funx.Predicate.NotEqTest do + use ExUnit.Case, async: true + use Funx.Predicate + + alias Funx.Predicate.NotEq + + defmodule CustomError do + defstruct [:message] + end + + defmodule OtherError do + defstruct [:message] + end + + describe "NotEq predicate standalone" do + test "returns true when value does not equal reference" do + predicate = NotEq.pred(value: :active) + + assert predicate.(:deleted) + assert predicate.(:pending) + refute predicate.(:active) + end + + test "returns true when string does not equal reference" do + predicate = NotEq.pred(value: "hello") + + assert predicate.("world") + assert predicate.("HELLO") + refute predicate.("hello") + end + + test "returns true when number does not equal reference" do + predicate = NotEq.pred(value: 42) + + assert predicate.(41) + assert predicate.(43) + refute predicate.(42) + end + + test "checks struct type when expected is a module" do + predicate = NotEq.pred(value: CustomError) + + assert predicate.(%OtherError{message: "oops"}) + refute predicate.(%CustomError{message: "oops"}) + end + end + + describe "NotEq predicate in DSL" do + test "check with NotEq" do + not_deleted = + pred do + check :status, {NotEq, value: :deleted} + end + + assert not_deleted.(%{status: :active}) + assert not_deleted.(%{status: :pending}) + refute not_deleted.(%{status: :deleted}) + refute not_deleted.(%{}) + end + + test "negate check with NotEq (double negation)" do + is_deleted = + pred do + negate check :status, {NotEq, value: :deleted} + end + + assert is_deleted.(%{status: :deleted}) + refute is_deleted.(%{status: :active}) + end + + test "check struct type with NotEq" do + not_custom_error = + pred do + check :error, {NotEq, value: CustomError} + end + + assert not_custom_error.(%{error: %OtherError{message: "oops"}}) + refute not_custom_error.(%{error: %CustomError{message: "oops"}}) + end + end +end diff --git a/test/predicate/not_in_test.exs b/test/predicate/not_in_test.exs new file mode 100644 index 00000000..ac0bea70 --- /dev/null +++ b/test/predicate/not_in_test.exs @@ -0,0 +1,108 @@ +defmodule Funx.Predicate.NotInTest do + use ExUnit.Case, async: true + use Funx.Predicate + + alias Funx.Predicate.NotIn + + defmodule Click do + defstruct [:x, :y] + end + + defmodule Scroll do + defstruct [:delta] + end + + defmodule Submit do + defstruct [:form] + end + + describe "NotIn predicate standalone" do + test "returns true when value is not in list" do + predicate = NotIn.pred(values: [:deleted, :archived]) + + assert predicate.(:active) + assert predicate.(:pending) + refute predicate.(:deleted) + refute predicate.(:archived) + end + + test "returns true when string is not in list" do + predicate = NotIn.pred(values: ["admin", "root"]) + + assert predicate.("user") + assert predicate.("guest") + refute predicate.("admin") + refute predicate.("root") + end + + test "returns true when number is not in list" do + predicate = NotIn.pred(values: [0, -1]) + + assert predicate.(1) + assert predicate.(100) + refute predicate.(0) + refute predicate.(-1) + end + + test "checks struct type when values are all modules" do + predicate = NotIn.pred(values: [Click, Scroll]) + + assert predicate.(%Submit{form: "contact"}) + refute predicate.(%Click{x: 10, y: 20}) + refute predicate.(%Scroll{delta: 5}) + end + end + + describe "NotIn predicate in DSL" do + test "check with NotIn" do + not_deprecated = + pred do + check :status, {NotIn, values: [:deleted, :archived]} + end + + assert not_deprecated.(%{status: :active}) + assert not_deprecated.(%{status: :pending}) + refute not_deprecated.(%{status: :deleted}) + refute not_deprecated.(%{status: :archived}) + refute not_deprecated.(%{}) + end + + test "negate check with NotIn (double negation)" do + is_deprecated = + pred do + negate check :status, {NotIn, values: [:deleted, :archived]} + end + + assert is_deprecated.(%{status: :deleted}) + assert is_deprecated.(%{status: :archived}) + refute is_deprecated.(%{status: :active}) + end + + test "check struct type with NotIn" do + not_click_or_scroll = + pred do + check :event, {NotIn, values: [Click, Scroll]} + end + + assert not_click_or_scroll.(%{event: %Submit{form: "contact"}}) + refute not_click_or_scroll.(%{event: %Click{x: 10, y: 20}}) + refute not_click_or_scroll.(%{event: %Scroll{delta: 5}}) + end + end + + describe "combined with In" do + alias Funx.Predicate.In + + test "NotIn and In are complementary" do + allowed = [:active, :pending] + + in_predicate = In.pred(values: allowed) + not_in_predicate = NotIn.pred(values: allowed) + + # For any value, exactly one should be true + for value <- [:active, :pending, :deleted, :archived] do + assert in_predicate.(value) != not_in_predicate.(value) + end + end + end +end diff --git a/test/predicate/pattern_test.exs b/test/predicate/pattern_test.exs new file mode 100644 index 00000000..ac73e939 --- /dev/null +++ b/test/predicate/pattern_test.exs @@ -0,0 +1,84 @@ +defmodule Funx.Predicate.PatternTest do + use ExUnit.Case, async: true + use Funx.Predicate + + alias Funx.Predicate.{Pattern, Required} + + describe "Pattern predicate standalone" do + test "returns true when string matches regex" do + predicate = Pattern.pred(regex: ~r/^[A-Z]+$/) + + assert predicate.("ABC") + assert predicate.("HELLO") + refute predicate.("abc") + refute predicate.("Hello") + refute predicate.("123") + end + + test "returns true for partial matches" do + predicate = Pattern.pred(regex: ~r/@/) + + assert predicate.("test@example.com") + assert predicate.("@") + refute predicate.("no at sign") + end + + test "works with anchored patterns" do + predicate = Pattern.pred(regex: ~r/^\d{3}-\d{4}$/) + + assert predicate.("123-4567") + refute predicate.("12-4567") + refute predicate.("123-456") + refute predicate.("prefix 123-4567 suffix") + end + + test "returns false for non-strings" do + predicate = Pattern.pred(regex: ~r/.*/) + + refute predicate.(123) + refute predicate.(nil) + refute predicate.([:a, :b]) + refute predicate.(%{}) + end + end + + describe "Pattern predicate in DSL" do + test "check with Pattern" do + valid_code = + pred do + check :code, {Pattern, regex: ~r/^[A-Z]{3}$/} + end + + assert valid_code.(%{code: "ABC"}) + assert valid_code.(%{code: "XYZ"}) + refute valid_code.(%{code: "AB"}) + refute valid_code.(%{code: "ABCD"}) + refute valid_code.(%{code: "abc"}) + refute valid_code.(%{}) + end + + test "negate check with Pattern" do + invalid_format = + pred do + negate check :code, {Pattern, regex: ~r/^[A-Z]+$/} + end + + assert invalid_format.(%{code: "abc"}) + assert invalid_format.(%{code: "123"}) + refute invalid_format.(%{code: "ABC"}) + end + + test "combined with Required" do + valid_email = + pred do + check :email, Required + check :email, {Pattern, regex: ~r/@/} + end + + assert valid_email.(%{email: "test@example.com"}) + refute valid_email.(%{email: "invalid"}) + refute valid_email.(%{email: ""}) + refute valid_email.(%{email: nil}) + end + end +end diff --git a/test/predicate/positive_test.exs b/test/predicate/positive_test.exs new file mode 100644 index 00000000..cb32f25e --- /dev/null +++ b/test/predicate/positive_test.exs @@ -0,0 +1,80 @@ +defmodule Funx.Predicate.PositiveTest do + use ExUnit.Case, async: true + use Funx.Predicate + + alias Funx.Predicate.{Integer, Positive} + + describe "Positive predicate standalone" do + test "returns true for positive numbers" do + predicate = Positive.pred() + + assert predicate.(1) + assert predicate.(0.1) + assert predicate.(1_000_000) + assert predicate.(0.0001) + end + + test "returns false for zero" do + predicate = Positive.pred() + + refute predicate.(0) + refute predicate.(0.0) + end + + test "returns false for negative numbers" do + predicate = Positive.pred() + + refute predicate.(-1) + refute predicate.(-0.1) + refute predicate.(-1_000_000) + end + + test "returns false for non-numbers" do + predicate = Positive.pred() + + refute predicate.("5") + refute predicate.(:positive) + refute predicate.(nil) + refute predicate.([1, 2, 3]) + end + end + + describe "Positive predicate in DSL" do + test "check with Positive" do + positive_amount = + pred do + check :amount, Positive + end + + assert positive_amount.(%{amount: 100}) + assert positive_amount.(%{amount: 0.01}) + refute positive_amount.(%{amount: 0}) + refute positive_amount.(%{amount: -50}) + refute positive_amount.(%{}) + end + + test "negate check with Positive" do + not_positive = + pred do + negate check :value, Positive + end + + assert not_positive.(%{value: 0}) + assert not_positive.(%{value: -5}) + refute not_positive.(%{value: 5}) + end + + test "combined with Integer" do + positive_integer = + pred do + check :count, Integer + check :count, Positive + end + + assert positive_integer.(%{count: 5}) + refute positive_integer.(%{count: 5.5}) + refute positive_integer.(%{count: 0}) + refute positive_integer.(%{count: -5}) + end + end +end diff --git a/test/predicate/required_test.exs b/test/predicate/required_test.exs new file mode 100644 index 00000000..e446b388 --- /dev/null +++ b/test/predicate/required_test.exs @@ -0,0 +1,123 @@ +defmodule Funx.Predicate.RequiredTest do + use ExUnit.Case, async: true + use Funx.Predicate + + alias Funx.Predicate.{MinLength, Required} + + describe "Required predicate standalone" do + test "returns true for non-nil, non-empty values" do + predicate = Required.pred() + + assert predicate.("hello") + assert predicate.("a") + assert predicate.(123) + assert predicate.(%{key: "value"}) + assert predicate.([:a, :b]) + end + + test "returns true for falsy but present values" do + predicate = Required.pred() + + assert predicate.(false) + assert predicate.(0) + assert predicate.([]) + assert predicate.(%{}) + end + + test "returns false for nil" do + predicate = Required.pred() + + refute predicate.(nil) + end + + test "returns false for empty string" do + predicate = Required.pred() + + refute predicate.("") + end + end + + describe "Required predicate in DSL" do + test "check with Required" do + has_name = + pred do + check :name, Required + end + + assert has_name.(%{name: "Joe"}) + assert has_name.(%{name: "a"}) + refute has_name.(%{name: nil}) + refute has_name.(%{name: ""}) + refute has_name.(%{}) + end + + test "passes for falsy but present values" do + has_value = + pred do + check :value, Required + end + + assert has_value.(%{value: false}) + assert has_value.(%{value: 0}) + assert has_value.(%{value: []}) + end + + test "negate check with Required" do + missing_or_empty = + pred do + negate check :name, Required + end + + assert missing_or_empty.(%{name: nil}) + assert missing_or_empty.(%{name: ""}) + refute missing_or_empty.(%{name: "Joe"}) + end + + test "combined with other predicates" do + valid_name = + pred do + check :name, Required + check :name, {MinLength, min: 2} + end + + assert valid_name.(%{name: "Joe"}) + refute valid_name.(%{name: "J"}) + refute valid_name.(%{name: ""}) + refute valid_name.(%{name: nil}) + end + end + + describe "Required vs truthy" do + test "Required passes for false, truthy does not" do + required_check = + pred do + check :enabled, Required + end + + truthy_check = + pred do + check :enabled + end + + # false is present but falsy + assert required_check.(%{enabled: false}) + refute truthy_check.(%{enabled: false}) + end + + test "Required passes for 0, truthy passes too" do + required_check = + pred do + check :count, Required + end + + truthy_check = + pred do + check :count + end + + # 0 is present and truthy in Elixir + assert required_check.(%{count: 0}) + assert truthy_check.(%{count: 0}) + end + end +end diff --git a/test/predicate/true_test.exs b/test/predicate/true_test.exs new file mode 100644 index 00000000..b1e146b8 --- /dev/null +++ b/test/predicate/true_test.exs @@ -0,0 +1,122 @@ +defmodule Funx.Predicate.IsTrueTest do + use ExUnit.Case, async: true + use Funx.Predicate + + alias Funx.Predicate.IsTrue + + describe "IsTrue predicate standalone" do + test "returns true for true value" do + predicate = IsTrue.pred() + + assert predicate.(true) + end + + test "returns false for false value" do + predicate = IsTrue.pred() + + refute predicate.(false) + end + + test "returns false for truthy values (strict equality)" do + predicate = IsTrue.pred() + + refute predicate.(1) + refute predicate.("true") + refute predicate.(:true_atom) + refute predicate.([]) + refute predicate.(%{}) + end + + test "returns false for nil" do + predicate = IsTrue.pred() + + refute predicate.(nil) + end + end + + describe "IsTrue predicate in DSL" do + test "check with IsTrue for boolean flag" do + is_active = + pred do + check :active, IsTrue + end + + assert is_active.(%{active: true}) + refute is_active.(%{active: false}) + refute is_active.(%{}) + end + + test "check with IsTrue using tuple syntax" do + is_active = + pred do + check :active, {IsTrue, []} + end + + assert is_active.(%{active: true}) + refute is_active.(%{active: false}) + end + + test "check with nested path" do + is_poisoned = + pred do + check [:poison, :active], IsTrue + end + + assert is_poisoned.(%{poison: %{active: true}}) + refute is_poisoned.(%{poison: %{active: false}}) + refute is_poisoned.(%{poison: %{}}) + refute is_poisoned.(%{}) + end + + test "negate check with IsTrue" do + not_verified = + pred do + negate check :verified, IsTrue + end + + assert not_verified.(%{verified: false}) + assert not_verified.(%{}) + refute not_verified.(%{verified: true}) + end + + test "multiple IsTrue checks" do + all_flags_set = + pred do + check :active, IsTrue + check :verified, IsTrue + check :approved, IsTrue + end + + assert all_flags_set.(%{active: true, verified: true, approved: true}) + refute all_flags_set.(%{active: true, verified: false, approved: true}) + refute all_flags_set.(%{active: true, verified: true, approved: false}) + end + + test "combined with other predicates" do + valid_user = + pred do + check :active, IsTrue + check :age, fn age -> age >= 18 end + end + + assert valid_user.(%{active: true, age: 20}) + refute valid_user.(%{active: false, age: 20}) + refute valid_user.(%{active: true, age: 16}) + end + + test "within any block" do + has_access = + pred do + any do + check :admin, IsTrue + check :vip, IsTrue + end + end + + assert has_access.(%{admin: true, vip: false}) + assert has_access.(%{admin: false, vip: true}) + assert has_access.(%{admin: true, vip: true}) + refute has_access.(%{admin: false, vip: false}) + end + end +end diff --git a/test/validator/contains_test.exs b/test/validator/contains_test.exs new file mode 100644 index 00000000..4c41af2d --- /dev/null +++ b/test/validator/contains_test.exs @@ -0,0 +1,90 @@ +defmodule Funx.Validator.ContainsTest do + use ExUnit.Case, async: true + + alias Funx.Monad.Either + alias Funx.Monad.Maybe.{Just, Nothing} + alias Funx.Validator.Contains + + describe "Contains validator with matching values" do + test "passes when list contains element" do + result = Contains.validate([:a, :b, :c], value: :b) + assert result == Either.right([:a, :b, :c]) + end + + test "passes with single element list" do + result = Contains.validate([:target], value: :target) + assert result == Either.right([:target]) + end + + test "passes with strings" do + result = Contains.validate(["foo", "bar"], value: "bar") + assert result == Either.right(["foo", "bar"]) + end + + test "passes with integers" do + result = Contains.validate([1, 2, 3], value: 2) + assert result == Either.right([1, 2, 3]) + end + end + + describe "Contains validator with non-matching values" do + test "fails when list does not contain element" do + result = Contains.validate([:a, :b], value: :c) + assert Either.left?(result) + end + + test "fails with empty list" do + result = Contains.validate([], value: :anything) + assert Either.left?(result) + end + + test "fails with non-list value" do + result = Contains.validate(:not_a_list, value: :foo) + assert Either.left?(result) + end + end + + describe "Contains validator with custom message" do + test "uses custom message on failure" do + result = + Contains.validate([:a, :b], + value: :c, + message: fn _ -> "missing required element" end + ) + + assert %Either.Left{left: %{errors: ["missing required element"]}} = result + end + end + + describe "Contains validator with Maybe values" do + test "passes for Nothing" do + assert Contains.validate(%Nothing{}, value: :anything) == Either.right(%Nothing{}) + end + + test "passes for Just when list contains element" do + assert Contains.validate(%Just{value: [:a, :b]}, value: :a) == Either.right([:a, :b]) + end + + test "fails for Just when list does not contain element" do + result = Contains.validate(%Just{value: [:a, :b]}, value: :c) + assert Either.left?(result) + end + end + + describe "Contains validator default message" do + test "includes the expected element" do + result = Contains.validate([:a], value: :missing) + assert %Either.Left{left: %{errors: [message]}} = result + assert message =~ "must contain" + assert message =~ ":missing" + end + end + + describe "Contains validator argument validation" do + test "raises when :value option is missing" do + assert_raise KeyError, fn -> + Contains.validate([:a, :b], []) + end + end + end +end diff --git a/usage-rules/predicate.md b/usage-rules/predicate.md index ce3e43ac..6b7412f8 100644 --- a/usage-rules/predicate.md +++ b/usage-rules/predicate.md @@ -214,6 +214,70 @@ The DSL version: - `check &(&1.field), pred` - Function projection - `check fn x -> x.field end, pred` - Anonymous function projection +### Built-in Predicates + +Funx provides built-in predicate modules that implement `Funx.Predicate.Dsl.Behaviour`: + +| Module | Required Option | Description | +|--------|----------------|-------------| +| `Eq` | `value:` | Equality check using `Eq` comparator | +| `NotEq` | `value:` | Inequality check | +| `In` | `values:` | Membership in list | +| `NotIn` | `values:` | Exclusion from list | +| `LessThan` | `value:` | `< value` using `Ord` | +| `LessThanOrEqual` | `value:` | `<= value` using `Ord` | +| `GreaterThan` | `value:` | `> value` using `Ord` | +| `GreaterThanOrEqual` | `value:` | `>= value` using `Ord` | +| `IsTrue` | none | Strict `== true` | +| `IsFalse` | none | Strict `== false` | +| `MinLength` | `min:` | String length `>= min` | +| `MaxLength` | `max:` | String length `<= max` | +| `Pattern` | `regex:` | String matches regex | +| `Integer` | none | `is_integer/1` check | +| `Positive` | none | Number `> 0` | +| `Negative` | none | Number `< 0` | +| `Required` | none | Not `nil` and not `""` | +| `Contains` | `value:` | List contains element | + +**Usage syntax:** + +```elixir +# Without options (bare module) +pred do + check :count, Integer + check :count, Positive +end + +# With options (tuple syntax) +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 default is a truthy check: + +```elixir +pred do + check :name # equivalent to: check :name, fn v -> !!v end +end +``` + +Only `nil` and `false` are falsy in Elixir. Empty strings, `0`, and `[]` are truthy. + +**Required vs truthy:** + +| Value | Default (truthy) | `Required` | +|-------|-----------------|------------| +| `"hello"` | true | true | +| `""` | true | **false** | +| `nil` | false | false | +| `false` | false | **true** | +| `0` | true | true | + ### DSL Examples **Basic multi-condition predicate:** @@ -336,6 +400,21 @@ pred do end ``` +**Using built-in predicates:** + +```elixir +alias Funx.Predicate.{Eq, In, GreaterThanOrEqual, MinLength, Required, Pattern} + +pred do + check :status, {Eq, value: :active} + check :role, {In, values: [:admin, :user, :moderator]} + check :age, {GreaterThanOrEqual, value: 18} + check :name, Required + check :name, {MinLength, min: 2} + check :email, {Pattern, regex: ~r/@/} +end +``` + **Using behaviour modules:** ```elixir @@ -405,6 +484,7 @@ The Predicate DSL provides declarative multi-condition boolean logic: - Bare predicate - Must pass (AND) - `negate ` - Must fail (NOT) - `check , ` - Project then test +- `check ` - Project then truthy check (default) - `negate check , ` - Projected value must NOT match - `any do ... end` - OR logic (at least one must match) - `all do ... end` - AND logic (all must match) @@ -417,6 +497,7 @@ The Predicate DSL provides declarative multi-condition boolean logic: - **Lens** with `check` for required fields (total accessor, raises on missing keys) - **Prism** with `check` for sum type branch selection (selects one case, Nothing fails the predicate) - **Traversal** with `check` for relating multiple foci (collect values to compare or validate together) +- **Built-in predicates** for common checks: `Eq`, `In`, `LessThan`, `GreaterThan`, `MinLength`, `Pattern`, `Required`, `Integer`, `Positive`, etc. - Use behaviour modules for reusable, configurable predicate logic - Nested `any`/`all` blocks for complex boolean expressions - Works seamlessly with Enum functions for filtering and searching