From 52087950a7eb16c20b5a37d1393b623993ecfb5f Mon Sep 17 00:00:00 2001 From: Joseph Koski Date: Mon, 2 Feb 2026 11:25:52 -0800 Subject: [PATCH 1/9] Add basic predicates --- lib/predicate/dsl/parser.ex | 59 +++++++++- lib/predicate/eq.ex | 54 +++++++++ lib/predicate/greater_than.ex | 42 +++++++ lib/predicate/greater_than_or_equal.ex | 42 +++++++ lib/predicate/is_false.ex | 34 ++++++ lib/predicate/is_true.ex | 29 +++++ lib/predicate/less_than.ex | 42 +++++++ lib/predicate/less_than_or_equal.ex | 42 +++++++ lib/validator/any.ex | 9 -- lib/validator/equal.ex | 15 +-- lib/validator/greater_than.ex | 7 +- lib/validator/greater_than_or_eq.ex | 7 +- lib/validator/less_than.ex | 7 +- lib/validator/less_than_or_eq.ex | 7 +- livebooks/predicate/pred_dsl.livemd | 2 + test/predicate/comparison_test.exs | 155 +++++++++++++++++++++++++ test/predicate/eq_test.exs | 148 +++++++++++++++++++++++ test/predicate/false_test.exs | 125 ++++++++++++++++++++ test/predicate/true_test.exs | 122 +++++++++++++++++++ 19 files changed, 909 insertions(+), 39 deletions(-) create mode 100644 lib/predicate/eq.ex create mode 100644 lib/predicate/greater_than.ex create mode 100644 lib/predicate/greater_than_or_equal.ex create mode 100644 lib/predicate/is_false.ex create mode 100644 lib/predicate/is_true.ex create mode 100644 lib/predicate/less_than.ex create mode 100644 lib/predicate/less_than_or_equal.ex create mode 100644 test/predicate/comparison_test.exs create mode 100644 test/predicate/eq_test.exs create mode 100644 test/predicate/false_test.exs create mode 100644 test/predicate/true_test.exs 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/is_false.ex b/lib/predicate/is_false.ex new file mode 100644 index 00000000..f1869f0e --- /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..f3ccd56c --- /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/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/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/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/livebooks/predicate/pred_dsl.livemd b/livebooks/predicate/pred_dsl.livemd index 54e4d624..972e0589 100644 --- a/livebooks/predicate/pred_dsl.livemd +++ b/livebooks/predicate/pred_dsl.livemd @@ -361,7 +361,9 @@ end defmodule Settings do defstruct [:notifications, :privacy] end +``` +```elixir accounts = [ %Account{ owner: %Owner{name: "Alice", age: 30}, diff --git a/test/predicate/comparison_test.exs b/test/predicate/comparison_test.exs new file mode 100644 index 00000000..aa470bba --- /dev/null +++ b/test/predicate/comparison_test.exs @@ -0,0 +1,155 @@ +defmodule Funx.Predicate.ComparisonTest do + use ExUnit.Case, async: true + use Funx.Predicate + + alias Funx.Predicate.{GreaterThan, GreaterThanOrEqual, LessThan, 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/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..6e384670 --- /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/true_test.exs b/test/predicate/true_test.exs new file mode 100644 index 00000000..26c90d8d --- /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 From 0427c8d479b6a6755a55d26a7309759cb5a9cb3f Mon Sep 17 00:00:00 2001 From: Joseph Koski Date: Mon, 2 Feb 2026 14:04:58 -0800 Subject: [PATCH 2/9] Add In --- lib/predicate/in.ex | 54 ++++++++++ lib/validator/in.ex | 15 +-- test/predicate/dsl/predicate_dsl_test.exs | 72 ++++++++++++++ test/predicate/in_test.exs | 114 ++++++++++++++++++++++ 4 files changed, 243 insertions(+), 12 deletions(-) create mode 100644 lib/predicate/in.ex create mode 100644 test/predicate/in_test.exs 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/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/test/predicate/dsl/predicate_dsl_test.exs b/test/predicate/dsl/predicate_dsl_test.exs index 69355051..5cc0171a 100644 --- a/test/predicate/dsl/predicate_dsl_test.exs +++ b/test/predicate/dsl/predicate_dsl_test.exs @@ -403,6 +403,78 @@ defmodule Funx.Predicate.DslTest do 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 # ============================================================================ diff --git a/test/predicate/in_test.exs b/test/predicate/in_test.exs new file mode 100644 index 00000000..18ea6b98 --- /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 From 19a64e8786dfdda9632d6a5f186b437be561abf6 Mon Sep 17 00:00:00 2001 From: Joseph Koski Date: Mon, 2 Feb 2026 14:15:10 -0800 Subject: [PATCH 3/9] Add contains --- lib/predicate/contains.ex | 47 +++++++++++++++ lib/validator/contains.ex | 46 +++++++++++++++ test/predicate/contains_test.exs | 98 ++++++++++++++++++++++++++++++++ test/validator/contains_test.exs | 90 +++++++++++++++++++++++++++++ 4 files changed, 281 insertions(+) create mode 100644 lib/predicate/contains.ex create mode 100644 lib/validator/contains.ex create mode 100644 test/predicate/contains_test.exs create mode 100644 test/validator/contains_test.exs 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/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/test/predicate/contains_test.exs b/test/predicate/contains_test.exs new file mode 100644 index 00000000..afabebd2 --- /dev/null +++ b/test/predicate/contains_test.exs @@ -0,0 +1,98 @@ +defmodule Funx.Predicate.ContainsTest do + use ExUnit.Case, async: true + use Funx.Predicate + + 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/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 From f7c62d2c2e05871ccb6b8163b7e28af14ee7995d Mon Sep 17 00:00:00 2001 From: Joseph Koski Date: Tue, 3 Feb 2026 12:35:44 -0800 Subject: [PATCH 4/9] Extract predicates --- lib/predicate/integer.ex | 33 ++++++++ lib/predicate/max_length.ex | 37 +++++++++ lib/predicate/min_length.ex | 37 +++++++++ lib/predicate/negative.ex | 36 +++++++++ lib/predicate/not_eq.ex | 54 +++++++++++++ lib/predicate/not_in.ex | 54 +++++++++++++ lib/predicate/positive.ex | 36 +++++++++ lib/predicate/required.ex | 41 ++++++++++ lib/validator/integer.ex | 7 +- lib/validator/max_length.ex | 10 +-- lib/validator/min_length.ex | 10 +-- lib/validator/negative.ex | 9 ++- lib/validator/not_equal.ex | 47 +++-------- lib/validator/not_in.ex | 45 +++-------- lib/validator/positive.ex | 9 ++- lib/validator/required.ex | 7 +- test/predicate/comparison_test.exs | 5 ++ test/predicate/contains_test.exs | 2 + test/predicate/integer_test.exs | 74 +++++++++++++++++ test/predicate/length_test.exs | 108 +++++++++++++++++++++++++ test/predicate/negative_test.exs | 80 +++++++++++++++++++ test/predicate/not_eq_test.exs | 81 +++++++++++++++++++ test/predicate/not_in_test.exs | 108 +++++++++++++++++++++++++ test/predicate/positive_test.exs | 80 +++++++++++++++++++ test/predicate/required_test.exs | 123 +++++++++++++++++++++++++++++ 25 files changed, 1036 insertions(+), 97 deletions(-) create mode 100644 lib/predicate/integer.ex create mode 100644 lib/predicate/max_length.ex create mode 100644 lib/predicate/min_length.ex create mode 100644 lib/predicate/negative.ex create mode 100644 lib/predicate/not_eq.ex create mode 100644 lib/predicate/not_in.ex create mode 100644 lib/predicate/positive.ex create mode 100644 lib/predicate/required.ex create mode 100644 test/predicate/integer_test.exs create mode 100644 test/predicate/length_test.exs create mode 100644 test/predicate/negative_test.exs create mode 100644 test/predicate/not_eq_test.exs create mode 100644 test/predicate/not_in_test.exs create mode 100644 test/predicate/positive_test.exs create mode 100644 test/predicate/required_test.exs 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/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/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/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/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/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/test/predicate/comparison_test.exs b/test/predicate/comparison_test.exs index aa470bba..310cbb14 100644 --- a/test/predicate/comparison_test.exs +++ b/test/predicate/comparison_test.exs @@ -4,6 +4,11 @@ defmodule Funx.Predicate.ComparisonTest do 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) diff --git a/test/predicate/contains_test.exs b/test/predicate/contains_test.exs index afabebd2..534f78d4 100644 --- a/test/predicate/contains_test.exs +++ b/test/predicate/contains_test.exs @@ -2,6 +2,8 @@ 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 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/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 From ea926f9607c8aae46f03a7cfe7119e3d2719d2aa Mon Sep 17 00:00:00 2001 From: Joseph Koski Date: Wed, 4 Feb 2026 10:53:16 -0800 Subject: [PATCH 5/9] Add pattern --- CHANGELOG.md | 20 +++ guides/dsl/predicate.md | 75 ++++++++++ lib/predicate/pattern.ex | 41 +++++ lib/validator/pattern.ex | 10 +- livebooks/predicate/pred_dsl.livemd | 222 +++++++++++++++++++++++++++- test/predicate/pattern_test.exs | 84 +++++++++++ usage-rules/predicate.md | 81 ++++++++++ 7 files changed, 527 insertions(+), 6 deletions(-) create mode 100644 lib/predicate/pattern.ex create mode 100644 test/predicate/pattern_test.exs 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/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/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/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/livebooks/predicate/pred_dsl.livemd b/livebooks/predicate/pred_dsl.livemd index 972e0589..0b1695df 100644 --- a/livebooks/predicate/pred_dsl.livemd +++ b/livebooks/predicate/pred_dsl.livemd @@ -2,7 +2,7 @@ ```elixir Mix.install([ - {:funx, "0.8.2"} + {:funx, github: "JKWA/funx", ref: "f7c62d2"} ]) ``` @@ -62,6 +62,7 @@ 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): @@ -900,6 +901,223 @@ check_active_adult = Enum.filter(users, check_active_adult) ``` +## Built-in Predicates + +Funx provides built-in predicate modules that implement `Funx.Predicate.Dsl.Behaviour`. These can be used directly in the DSL with tuple syntax `{Module, opts}` or bare module syntax for predicates without required options. + +### Setup for Built-in Predicates + +```elixir +alias Funx.Predicate.{ + Eq, NotEq, + In, NotIn, + LessThan, LessThanOrEqual, GreaterThan, GreaterThanOrEqual, + IsTrue, IsFalse, + MinLength, MaxLength, + Integer, Positive, Negative, + Required, Pattern, Contains +} +``` + +### Equality Predicates + +`Eq` checks if a value equals an expected value. `NotEq` checks inequality. + +```elixir +check_active = + pred do + check :status, {Eq, value: :active} + end + +check_active.(%{status: :active}) # true +check_active.(%{status: :inactive}) # false +``` + +```elixir +not_deleted = + pred do + check :status, {NotEq, value: :deleted} + end + +not_deleted.(%{status: :active}) # true +not_deleted.(%{status: :deleted}) # false +``` + +### Membership Predicates + +`In` checks if a value is in a list. `NotIn` checks exclusion. + +```elixir +valid_role = + pred do + check :role, {In, values: [:admin, :moderator, :user]} + end + +valid_role.(%{role: :admin}) # true +valid_role.(%{role: :guest}) # false +``` + +```elixir +not_banned = + pred do + check :status, {NotIn, values: [:banned, :suspended]} + end + +not_banned.(%{status: :active}) # true +not_banned.(%{status: :banned}) # false +``` + +### Comparison Predicates + +Comparison predicates use `Ord` for ordering. + +```elixir +check_adult = + pred do + check :age, {GreaterThanOrEqual, value: 18} + end + +check_adult.(%{age: 21}) # true +check_adult.(%{age: 16}) # false +``` + +```elixir +check_discount = + pred do + check :price, {LessThan, value: 100} + end + +check_discount.(%{price: 50}) # true +check_discount.(%{price: 150}) # false +``` + +### Boolean Predicates + +`IsTrue` and `IsFalse` check strict boolean equality (`== true` / `== false`). + +```elixir +check_admin = + pred do + check :admin, IsTrue + end + +check_admin.(%{admin: true}) # true +check_admin.(%{admin: "yes"}) # false (not strictly true) +check_admin.(%{admin: false}) # false +``` + +### String Length Predicates + +```elixir +valid_username = + pred do + check :username, {MinLength, min: 3} + check :username, {MaxLength, max: 20} + end + +valid_username.(%{username: "alice"}) # true +valid_username.(%{username: "ab"}) # false (too short) +valid_username.(%{username: "verylongusernamehere123"}) # false (too long) +``` + +### Pattern Predicate + +`Pattern` checks if a string matches a regex. + +```elixir +valid_email = + pred do + check :email, {Pattern, regex: ~r/@/} + end + +valid_email.(%{email: "test@example.com"}) # true +valid_email.(%{email: "invalid"}) # false +``` + +```elixir +valid_code = + pred do + check :code, {Pattern, regex: ~r/^[A-Z]{3}-\d{3}$/} + end + +valid_code.(%{code: "ABC-123"}) # true +valid_code.(%{code: "abc-123"}) # false +``` + +### Numeric Predicates + +`Integer`, `Positive`, and `Negative` check numeric properties. + +```elixir +valid_quantity = + pred do + check :quantity, Integer + check :quantity, Positive + end + +valid_quantity.(%{quantity: 5}) # true +valid_quantity.(%{quantity: 0}) # false (not positive) +valid_quantity.(%{quantity: 5.5}) # false (not integer) +valid_quantity.(%{quantity: -1}) # false (not positive) +``` + +### Required Predicate + +`Required` checks that a value is not `nil` and not an empty string. Unlike the default truthy check, `Required` passes for `false` and `0`. + +```elixir +has_name = + pred do + check :name, Required + end + +has_name.(%{name: "Alice"}) # true +has_name.(%{name: ""}) # false (empty string) +has_name.(%{name: nil}) # false +has_name.(%{name: false}) # true (false is a present value) +``` + +### Contains Predicate + +`Contains` checks if a list contains a specific element. + +```elixir +has_admin_role = + pred do + check :roles, {Contains, value: :admin} + end + +has_admin_role.(%{roles: [:user, :admin]}) # true +has_admin_role.(%{roles: [:user]}) # false +``` + +### Default Truthy Check + +When using `check` with only a projection (no predicate), the default is a truthy check (`!!value`): + +```elixir +has_name = + pred do + check :name # equivalent to: check :name, fn v -> !!v end + end + +has_name.(%{name: "Alice"}) # true +has_name.(%{name: ""}) # true (empty string is truthy in Elixir) +has_name.(%{name: nil}) # false +has_name.(%{name: false}) # false +``` + +### Comparison: Default vs Required vs IsTrue + +| 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 | + ## Helper Functions Define reusable predicates as 0-arity helper functions: @@ -1189,6 +1407,8 @@ The Predicate DSL provides a declarative, composable way to build complex boolea * Behaviours for reusable validation logic +* **Built-in predicates** for common checks: `Eq`, `In`, `LessThan`, `GreaterThan`, `MinLength`, `MaxLength`, `Pattern`, `Required`, `Integer`, `Positive`, `Negative`, and more + * `any` blocks for OR logic * `all` blocks for explicit AND logic 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/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 From 268a4ae70b68ffe9c26626e2e06d128926c47dbb Mon Sep 17 00:00:00 2001 From: Joseph Koski Date: Wed, 4 Feb 2026 11:20:12 -0800 Subject: [PATCH 6/9] update formatting check/1 --- .formatter.exs | 1 + FORMATTER_EXPORT.md | 1 + livebooks/predicate/pred_dsl.livemd | 1363 ++++----------------- test/predicate/contains_test.exs | 2 +- test/predicate/dsl/predicate_dsl_test.exs | 78 +- test/predicate/in_test.exs | 2 +- 6 files changed, 248 insertions(+), 1199 deletions(-) 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/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/livebooks/predicate/pred_dsl.livemd b/livebooks/predicate/pred_dsl.livemd index 0b1695df..96d3cf9f 100644 --- a/livebooks/predicate/pred_dsl.livemd +++ b/livebooks/predicate/pred_dsl.livemd @@ -8,23 +8,19 @@ Mix.install([ ## 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,52 +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] @@ -100,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 @@ -118,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} @@ -130,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? @@ -217,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` - -**Traversal** -Selects: multiple related foci -Predicate input: list -Order: declaration order - -### 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.** +The `check` directive composes a projection with a predicate. -> **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. +### Atom Fields (Prism.key) -Prisms are designed for sum types (tagged unions) where data can be in one of several variants: +Atoms convert to `Prism.key/1`. When field is absent or `nil`, check returns false: ```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: - -```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) -``` - -```elixir -check_long_name.(%User{name: "Joe"}) +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 ``` -### check with Lens +### Lens (Total Access) -Lenses provide total access to fields: +Lenses always succeed - use for required fields: ```elixir check_adult_by_age = @@ -306,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) - -Prisms select branches of sum types. Use `Prism.struct/1` to match on a specific variant: - -```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) -``` +### Prism (Sum Type Branch Selection) -```elixir -check_large_completed.(order_pending) # false (branch doesn't match - predicate excluded) -``` +Prisms select one case of a sum type. Predicate only runs when branch matches: ```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 @@ -366,1067 +207,273 @@ end ```elixir 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} - } + %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 = +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_owner) +Enum.filter(accounts, check_adult_with_notifications) ``` -Check multiple nested fields: +### Traversal (Relating Multiple Foci) + +Traversal predicates receive a list of all focused values: ```elixir -check_adult_notifications = +defmodule Transaction do + defstruct [:charge_amount, :refund_amount, :status] +end + +check_valid_refund = pred do - check [:owner, :age], fn age -> age >= 18 end - check [:settings, :notifications], fn n -> n == true end + check Traversal.combine([Lens.key(:charge_amount), Lens.key(:refund_amount)]), fn + [charge, refund] -> charge == refund + _ -> false + end end -Enum.filter(accounts, check_adult_notifications) +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 ``` -List paths work with struct modules for type-safe access: +## `negate check`: Excluding Matches ```elixir -check_struct_path = +# Adult who is NOT banned (active != false) +valid_user = pred do - check [Account, :owner, Owner, :age], fn age -> age >= 21 end + check :age, fn age -> age >= 18 end + negate check :active, fn active -> active == false end end -Enum.filter(accounts, check_struct_path) +valid_user.(alice) # true +valid_user.(charlie) # false - not active ``` -Negating list path checks: +## `any` and `all`: Boolean Logic + +### OR Logic with `any` ```elixir -check_not_private = +check_admin_or_verified = pred do - negate check [:settings, :privacy], fn p -> p == :private end + any do + fn user -> user.role == :admin end + fn user -> user.verified end + end end -Enum.filter(accounts, check_not_private) +Enum.filter(users, check_admin_or_verified) # Alice (admin), Charlie (verified) ``` -List paths are syntax sugar for `Prism.path`: +### Nested Blocks -```elixir -# These are equivalent: -check_list = pred do - check [:owner, :age], fn age -> age >= 18 end -end +Active AND (admin OR (verified AND adult)): -check_explicit = pred do - check Prism.path([:owner, :age]), fn age -> age >= 18 end -end +```elixir +check_complex = + pred do + active? + any do + fn user -> user.role == :admin end + all do + verified? + adult? + end + end + end -Enum.filter(accounts, check_list) == Enum.filter(accounts, check_explicit) +Enum.filter(users, check_complex) ``` -### check with Traversal (Relating Multiple Foci) +## De Morgan's Laws -**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.** +`negate_all` and `negate_any` apply De Morgan's Laws: -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: - -```elixir -defmodule Transaction do - defstruct [:charge_amount, :refund_amount, :status] -end -``` +* `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 -# Check that refund matches original charge (relating two foci) -check_valid_refund = +# Regular users only (not vip, not sponsor, not admin) +regular_user = pred do - check Traversal.combine([ - Lens.key(:charge_amount), - Lens.key(:refund_amount) - ]), fn values -> - case values do - [charge, refund] -> charge == refund - _ -> false - end + negate_any do + fn user -> user.vip end + fn user -> user.sponsor end + fn user -> user.role == :admin end end end -transaction = %Transaction{charge_amount: 100, refund_amount: 100, status: :refunded} -check_valid_refund.(transaction) +regular_user.(%{vip: false, sponsor: false, role: :user}) # true +regular_user.(%{vip: true, sponsor: false, role: :user}) # false ``` -```elixir -# Fails when amounts don't match -invalid_transaction = %Transaction{charge_amount: 100, refund_amount: 50, status: :refunded} -check_valid_refund.(invalid_transaction) -``` - -## `negate check`: Excluding Matches - -You can negate `check` directives to test that a projected value does NOT match a condition. +## Behaviour Modules -### negate check with Atom Fields +For reusable, configurable predicates: ```elixir -# Check that name is NOT long -is_long = fn name -> String.length(name) > 5 end +defmodule HasMinimumAge do + @behaviour Funx.Predicate.Dsl.Behaviour + + @impl true + def pred(opts) do + minimum = Keyword.get(opts, :minimum, 18) + fn user -> user.age >= minimum end + end +end -not_long_name = +check_21_plus = pred do - negate check :name, is_long + {HasMinimumAge, minimum: 21} end -not_long_name.(alice) +check_21_plus.(alice) # true - age 30 +check_21_plus.(%User{age: 19}) # false ``` +## Built-in Predicates + +Funx provides built-in predicate modules: + ```elixir -not_long_name.(%User{name: "Joe"}) +alias Funx.Predicate.{ + Eq, NotEq, In, NotIn, + LessThan, LessThanOrEqual, GreaterThan, GreaterThanOrEqual, + IsTrue, IsFalse, + MinLength, MaxLength, Pattern, + Integer, Positive, Negative, + Required, Contains +} ``` -### negate check with Age +### Examples ```elixir -# Check user is NOT a senior (< 65) -not_senior = +# Equality and membership +check_status = pred do - negate check :age, fn age -> age >= 65 end + check :status, {Eq, value: :active} + check :role, {In, values: [:admin, :moderator, :user]} end -not_senior.(alice) +check_status.(%{status: :active, role: :admin}) # true +check_status.(%{status: :inactive, role: :admin}) # false ``` -### Combining check and negate check - ```elixir -# Adult who is NOT banned -valid_user = +# Comparison predicates +check_adult = pred do - check :age, fn age -> age >= 18 end - negate check :active, fn active -> active == false end + check :age, {GreaterThanOrEqual, value: 18} end -valid_user.(alice) -``` - -```elixir -valid_user.(bob) +check_adult.(%{age: 21}) # true +check_adult.(%{age: 16}) # false ``` -### 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 = +# String validation +valid_username = pred do - negate check Prism.path([:status, OrderStatus.Completed]), fn _ -> true end + check :username, {MinLength, min: 3} + check :username, {MaxLength, max: 20} + check :email, {Pattern, regex: ~r/@/} end -not_completed.(order_pending) # true (not the Completed case) +valid_username.(%{username: "alice", email: "alice@example.com"}) # true +valid_username.(%{username: "ab", email: "alice@example.com"}) # false ``` ```elixir -not_completed.(order_cancelled) # true (not the Completed case) -``` +# Numeric predicates +valid_quantity = + pred do + check :quantity, Integer + check :quantity, Positive + end -```elixir -not_completed.(order_completed) # false (IS the Completed case) +valid_quantity.(%{quantity: 5}) # true +valid_quantity.(%{quantity: -1}) # false +valid_quantity.(%{quantity: 5.5}) # false ``` -## `any`: OR Logic +### Default Truthy vs Required -The `any` block succeeds if AT LEAST ONE nested predicate passes. - -### Simple any Block +When `check` has no predicate, it defaults to truthy (`!!value`): ```elixir -check_admin_or_verified = +truthy_check = pred do - any do - fn user -> user.role == :admin end - fn user -> user.verified end - end + check :name # equivalent to: fn v -> !!v end end -check_admin_or_verified.(alice) -``` +required_check = + pred do + check :name, Required # not nil AND not "" + end -Admin user passes even though not verified: +# Empty string is truthy but not Required +truthy_check.(%{name: ""}) # true +required_check.(%{name: ""}) # false -```elixir -check_admin_or_verified.(%User{role: :admin, verified: false}) +# false is falsy but passes Required (it's a present value) +truthy_check.(%{name: false}) # false +required_check.(%{name: false}) # true ``` -Neither condition passes: - -```elixir -check_admin_or_verified.(%User{role: :user, verified: false}) -``` +| 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 | -### any with Multiple Conditions +## Integration with Enum ```elixir -check_special_user = +check_eligible = 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 + check :age, {GreaterThanOrEqual, value: 18} + check :verified, IsTrue end -Enum.filter(users, check_special_user) +# 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) ``` -### Mixed Predicates with any +## Common Mistakes -Active AND (admin OR verified): +* **Assuming `check :field` passes `nil`** — It never does. When a field is absent or `nil`, the check returns false and your predicate never runs. -```elixir -check_active_special = - pred do - active? - any do - fn user -> user.role == :admin end - verified? - end - end +* **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. -Enum.filter(users, check_active_special) -``` +* **Expecting Traversal predicates to run per element** — They receive a list. Your predicate gets `[value1, value2, ...]`, not each value individually. -## `all`: Explicit AND +## Summary -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 - -Active AND (admin OR (verified AND adult)): - -```elixir -check_complex = - pred do - active? - any do - fn user -> user.role == :admin end - all do - verified? - adult? - end - end - end - -Enum.filter(users, check_complex) -``` - -### any Containing all Blocks - -(Admin AND verified) OR (moderator AND adult): - -```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 - -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: - -```elixir -# Regular users only (not vip, not sponsor, not admin) -regular_user = - pred do - negate_any do - fn user -> user.vip end - fn user -> user.sponsor end - fn user -> user.role == :admin end - end - end - -# 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 -``` - -## 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: - -```elixir -defmodule HasMinimumAge do - @behaviour Funx.Predicate.Dsl.Behaviour - - @impl true - def pred(opts) do - minimum = Keyword.get(opts, :minimum, 18) - fn user -> user.age >= minimum end - end -end -``` - -### 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) -``` - -```elixir -check_21_plus.(%User{age: 19}) -``` - -### Combining Behaviours with Other Predicates - -```elixir -check_active_adult = - pred do - IsActive - {HasMinimumAge, minimum: 18} - end - -Enum.filter(users, check_active_adult) -``` - -## Built-in Predicates - -Funx provides built-in predicate modules that implement `Funx.Predicate.Dsl.Behaviour`. These can be used directly in the DSL with tuple syntax `{Module, opts}` or bare module syntax for predicates without required options. - -### Setup for Built-in Predicates - -```elixir -alias Funx.Predicate.{ - Eq, NotEq, - In, NotIn, - LessThan, LessThanOrEqual, GreaterThan, GreaterThanOrEqual, - IsTrue, IsFalse, - MinLength, MaxLength, - Integer, Positive, Negative, - Required, Pattern, Contains -} -``` - -### Equality Predicates - -`Eq` checks if a value equals an expected value. `NotEq` checks inequality. - -```elixir -check_active = - pred do - check :status, {Eq, value: :active} - end - -check_active.(%{status: :active}) # true -check_active.(%{status: :inactive}) # false -``` - -```elixir -not_deleted = - pred do - check :status, {NotEq, value: :deleted} - end - -not_deleted.(%{status: :active}) # true -not_deleted.(%{status: :deleted}) # false -``` - -### Membership Predicates - -`In` checks if a value is in a list. `NotIn` checks exclusion. - -```elixir -valid_role = - pred do - check :role, {In, values: [:admin, :moderator, :user]} - end - -valid_role.(%{role: :admin}) # true -valid_role.(%{role: :guest}) # false -``` - -```elixir -not_banned = - pred do - check :status, {NotIn, values: [:banned, :suspended]} - end - -not_banned.(%{status: :active}) # true -not_banned.(%{status: :banned}) # false -``` - -### Comparison Predicates - -Comparison predicates use `Ord` for ordering. - -```elixir -check_adult = - pred do - check :age, {GreaterThanOrEqual, value: 18} - end - -check_adult.(%{age: 21}) # true -check_adult.(%{age: 16}) # false -``` - -```elixir -check_discount = - pred do - check :price, {LessThan, value: 100} - end - -check_discount.(%{price: 50}) # true -check_discount.(%{price: 150}) # false -``` - -### Boolean Predicates - -`IsTrue` and `IsFalse` check strict boolean equality (`== true` / `== false`). - -```elixir -check_admin = - pred do - check :admin, IsTrue - end - -check_admin.(%{admin: true}) # true -check_admin.(%{admin: "yes"}) # false (not strictly true) -check_admin.(%{admin: false}) # false -``` - -### String Length Predicates - -```elixir -valid_username = - pred do - check :username, {MinLength, min: 3} - check :username, {MaxLength, max: 20} - end - -valid_username.(%{username: "alice"}) # true -valid_username.(%{username: "ab"}) # false (too short) -valid_username.(%{username: "verylongusernamehere123"}) # false (too long) -``` - -### Pattern Predicate - -`Pattern` checks if a string matches a regex. - -```elixir -valid_email = - pred do - check :email, {Pattern, regex: ~r/@/} - end - -valid_email.(%{email: "test@example.com"}) # true -valid_email.(%{email: "invalid"}) # false -``` - -```elixir -valid_code = - pred do - check :code, {Pattern, regex: ~r/^[A-Z]{3}-\d{3}$/} - end - -valid_code.(%{code: "ABC-123"}) # true -valid_code.(%{code: "abc-123"}) # false -``` - -### Numeric Predicates - -`Integer`, `Positive`, and `Negative` check numeric properties. - -```elixir -valid_quantity = - pred do - check :quantity, Integer - check :quantity, Positive - end - -valid_quantity.(%{quantity: 5}) # true -valid_quantity.(%{quantity: 0}) # false (not positive) -valid_quantity.(%{quantity: 5.5}) # false (not integer) -valid_quantity.(%{quantity: -1}) # false (not positive) -``` - -### Required Predicate - -`Required` checks that a value is not `nil` and not an empty string. Unlike the default truthy check, `Required` passes for `false` and `0`. - -```elixir -has_name = - pred do - check :name, Required - end - -has_name.(%{name: "Alice"}) # true -has_name.(%{name: ""}) # false (empty string) -has_name.(%{name: nil}) # false -has_name.(%{name: false}) # true (false is a present value) -``` - -### Contains Predicate - -`Contains` checks if a list contains a specific element. - -```elixir -has_admin_role = - pred do - check :roles, {Contains, value: :admin} - end - -has_admin_role.(%{roles: [:user, :admin]}) # true -has_admin_role.(%{roles: [:user]}) # false -``` - -### Default Truthy Check - -When using `check` with only a projection (no predicate), the default is a truthy check (`!!value`): - -```elixir -has_name = - pred do - check :name # equivalent to: check :name, fn v -> !!v end - end - -has_name.(%{name: "Alice"}) # true -has_name.(%{name: ""}) # true (empty string is truthy in Elixir) -has_name.(%{name: nil}) # false -has_name.(%{name: false}) # false -``` - -### Comparison: Default vs Required vs IsTrue - -| 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 | - -## Helper Functions - -Define reusable predicates as 0-arity helper functions: - -```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 = - pred do - PredicateHelpers.adult? - PredicateHelpers.verified? - end - -Enum.filter(users, check_verified_adult) -``` - -## 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 = - pred do - check OpticHelpers.name_prism, fn name -> String.length(name) > 5 end - 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} -] -``` - -### Complex Order Filtering - -Find high-value completed orders or any pending order using Prism for branch selection: - -```elixir -check_important_order = - 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 - end - -Enum.filter(orders, check_important_order) -``` - -### Using check with Orders - -```elixir -check_bulk_order = - pred do - check Lens.key(:items), fn items -> items >= 10 end - end - -Enum.filter(orders, check_bulk_order) -``` - -## Edge Cases - -### Empty pred Block - -An empty `pred` block returns a predicate that always returns `true`: - -```elixir -check_any = - pred do - 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 = - pred do - any do - adult? - end - end - -check_single_any.(alice) -``` - -### Single Predicate in all Block - -```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) -``` - -## 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") -``` - -## Pattern: Count Matching Items - -```elixir -active_users = - pred do - active? - 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 -``` - -## 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 - -* **Built-in predicates** for common checks: `Eq`, `In`, `LessThan`, `GreaterThan`, `MinLength`, `MaxLength`, `Pattern`, `Required`, `Integer`, `Positive`, `Negative`, and more - -* `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/contains_test.exs b/test/predicate/contains_test.exs index 534f78d4..8b91206d 100644 --- a/test/predicate/contains_test.exs +++ b/test/predicate/contains_test.exs @@ -80,7 +80,7 @@ defmodule Funx.Predicate.ContainsTest do test "combined with other predicates" do valid_user = pred do - check(:active) + check :active check :permissions, {Contains, value: :read} end diff --git a/test/predicate/dsl/predicate_dsl_test.exs b/test/predicate/dsl/predicate_dsl_test.exs index 5cc0171a..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,7 +396,7 @@ 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) @@ -411,7 +411,7 @@ defmodule Funx.Predicate.DslTest do test "single-argument check defaults to truthy" do is_active = pred do - check(:active) + check :active end assert is_active.(%{active: true}) @@ -425,7 +425,7 @@ defmodule Funx.Predicate.DslTest do test "single-argument check with nested path" do is_poisoned = pred do - check([:poison, :active]) + check [:poison, :active] end assert is_poisoned.(%{poison: %{active: true}}) @@ -439,7 +439,7 @@ defmodule Funx.Predicate.DslTest do test "negate single-argument check" do not_active = pred do - negate check(:active) + negate check :active end assert not_active.(%{active: false}) @@ -452,9 +452,9 @@ defmodule Funx.Predicate.DslTest do test "multiple single-argument checks" do all_flags = pred do - check(:active) - check(:verified) - check(:approved) + check :active + check :verified + check :approved end assert all_flags.(%{active: true, verified: true, approved: true}) @@ -465,7 +465,7 @@ defmodule Funx.Predicate.DslTest do test "mixed single and two-argument checks" do valid_user = pred do - check(:active) + check :active check :age, fn age -> age >= 18 end end @@ -483,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"}) @@ -493,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}) @@ -503,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}) @@ -563,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}) @@ -732,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"}) @@ -744,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}) @@ -756,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}) @@ -766,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}) @@ -807,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}) @@ -819,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}) @@ -832,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}) @@ -845,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 @@ -861,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 @@ -878,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/in_test.exs b/test/predicate/in_test.exs index 18ea6b98..e78d4dc1 100644 --- a/test/predicate/in_test.exs +++ b/test/predicate/in_test.exs @@ -95,7 +95,7 @@ defmodule Funx.Predicate.InTest do valid_user = pred do check :status, {In, values: [:active, :pending]} - check(:verified) + check :verified end assert valid_user.(%{status: :active, verified: true}) From d287d9e2fd54ef58d9a01084689b8b305388db74 Mon Sep 17 00:00:00 2001 From: Joseph Koski Date: Wed, 4 Feb 2026 12:42:19 -0800 Subject: [PATCH 7/9] small update --- lib/predicate/is_false.ex | 4 ++-- lib/predicate/is_true.ex | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/predicate/is_false.ex b/lib/predicate/is_false.ex index f1869f0e..61788cbc 100644 --- a/lib/predicate/is_false.ex +++ b/lib/predicate/is_false.ex @@ -11,7 +11,7 @@ defmodule Funx.Predicate.IsFalse do # Check if a flag is false pred do - check [:bleeding, :staunched], {IsFalse, []} + check [:bleeding, :staunched], IsFalse end # Equivalent to @@ -21,7 +21,7 @@ defmodule Funx.Predicate.IsFalse do # Also equivalent to pred do - negate check [:bleeding, :staunched], {IsTrue, []} + negate check [:bleeding, :staunched], IsTrue end """ diff --git a/lib/predicate/is_true.ex b/lib/predicate/is_true.ex index f3ccd56c..fa173418 100644 --- a/lib/predicate/is_true.ex +++ b/lib/predicate/is_true.ex @@ -11,7 +11,7 @@ defmodule Funx.Predicate.IsTrue do # Check if a flag is true pred do - check [:poison, :active], {IsTrue, []} + check [:poison, :active], IsTrue end # Equivalent to From 7dbb23feeccb191de2ade846ba79ea7d1220818d Mon Sep 17 00:00:00 2001 From: Joseph Koski Date: Wed, 4 Feb 2026 12:43:31 -0800 Subject: [PATCH 8/9] update ref --- livebooks/predicate/pred_dsl.livemd | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/livebooks/predicate/pred_dsl.livemd b/livebooks/predicate/pred_dsl.livemd index 96d3cf9f..752f64d9 100644 --- a/livebooks/predicate/pred_dsl.livemd +++ b/livebooks/predicate/pred_dsl.livemd @@ -2,7 +2,7 @@ ```elixir Mix.install([ - {:funx, github: "JKWA/funx", ref: "f7c62d2"} + {:funx, github: "JKWA/funx", ref: "d287d9e"} ]) ``` From d57c3202e9201b7779061df81c1ae4fb17da7d62 Mon Sep 17 00:00:00 2001 From: Joseph Koski Date: Wed, 4 Feb 2026 14:06:15 -0800 Subject: [PATCH 9/9] add default [] --- lib/predicate/is_false.ex | 2 +- lib/predicate/is_true.ex | 2 +- test/predicate/false_test.exs | 8 ++++---- test/predicate/true_test.exs | 8 ++++---- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/lib/predicate/is_false.ex b/lib/predicate/is_false.ex index 61788cbc..72372da5 100644 --- a/lib/predicate/is_false.ex +++ b/lib/predicate/is_false.ex @@ -28,7 +28,7 @@ defmodule Funx.Predicate.IsFalse do @behaviour Funx.Predicate.Dsl.Behaviour @impl true - def pred(_opts) do + 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 index fa173418..e4f17bf1 100644 --- a/lib/predicate/is_true.ex +++ b/lib/predicate/is_true.ex @@ -23,7 +23,7 @@ defmodule Funx.Predicate.IsTrue do @behaviour Funx.Predicate.Dsl.Behaviour @impl true - def pred(_opts) do + def pred(_opts \\ []) do fn value -> value == true end end end diff --git a/test/predicate/false_test.exs b/test/predicate/false_test.exs index 6e384670..0ff0c1de 100644 --- a/test/predicate/false_test.exs +++ b/test/predicate/false_test.exs @@ -6,19 +6,19 @@ defmodule Funx.Predicate.IsFalseTest do describe "IsFalse predicate standalone" do test "returns true for false value" do - predicate = IsFalse.pred([]) + predicate = IsFalse.pred() assert predicate.(false) end test "returns false for true value" do - predicate = IsFalse.pred([]) + predicate = IsFalse.pred() refute predicate.(true) end test "returns false for falsy values (strict equality)" do - predicate = IsFalse.pred([]) + predicate = IsFalse.pred() refute predicate.(nil) refute predicate.(0) @@ -26,7 +26,7 @@ defmodule Funx.Predicate.IsFalseTest do end test "returns false for truthy values" do - predicate = IsFalse.pred([]) + predicate = IsFalse.pred() refute predicate.(1) refute predicate.("false") diff --git a/test/predicate/true_test.exs b/test/predicate/true_test.exs index 26c90d8d..b1e146b8 100644 --- a/test/predicate/true_test.exs +++ b/test/predicate/true_test.exs @@ -6,19 +6,19 @@ defmodule Funx.Predicate.IsTrueTest do describe "IsTrue predicate standalone" do test "returns true for true value" do - predicate = IsTrue.pred([]) + predicate = IsTrue.pred() assert predicate.(true) end test "returns false for false value" do - predicate = IsTrue.pred([]) + predicate = IsTrue.pred() refute predicate.(false) end test "returns false for truthy values (strict equality)" do - predicate = IsTrue.pred([]) + predicate = IsTrue.pred() refute predicate.(1) refute predicate.("true") @@ -28,7 +28,7 @@ defmodule Funx.Predicate.IsTrueTest do end test "returns false for nil" do - predicate = IsTrue.pred([]) + predicate = IsTrue.pred() refute predicate.(nil) end