A custom function is provided by a module implementing the one-callback
Predicator.FunctionProvider behaviour, functions/0, which returns
%{name => {arity, atom}} - the same shape the four builtin modules use for
len, upper, abs, and the rest. Wire a provider module in with
Predicator.Context.new/2's providers: option:
defmodule MyApp.Predicates do
@behaviour Predicator.FunctionProvider
@impl Predicator.FunctionProvider
def functions, do: %{"is_admin" => {0, :call_is_admin}}
def call_is_admin([], context), do: {:ok, context.host.role == :admin}
endEvery function - builtin, provider, or inline closure - is called as
(args, context), where context is the %Predicator.Context{} the
evaluation is running under, not a bare data map.
The host slot
Context.new/2's host: option carries whatever a provider needs at call
time - a request struct, a tenant id, a database connection - separately
from the evaluated data, and put_host/2 replaces it in O(1) without
touching data. host is opaque: it is stored exactly as given, and there
is no syntax that reads it from predicate text directly - only a function can
see it. The same (args, context) convention applies to an inline closure,
so host: works there too:
iex> custom_functions = %{"is_admin" => {0, fn [], context -> {:ok, context.host.role == :admin} end}}
iex> context = Predicator.Context.new(%{}, functions: custom_functions, host: %{role: :admin})
iex> Predicator.evaluate("is_admin()", context)
{:ok, true}
iex> context = Predicator.Context.put_host(context, %{role: :guest})
iex> Predicator.evaluate("is_admin()", context)
{:ok, false}A context built only from providers: - no inline functions: closures -
is serializable: :erlang.term_to_binary/1 round-trips it, because a
module atom and a host term are both plain data. A context carrying inline
closures works identically but cannot be stored - a fun is not a term
:erlang.term_to_binary/1 can hand back to another process or a later run.
Reach for a provider module, not functions:, the moment a context needs to
be persisted or sent across a boundary.
Inline closures (convenience form)
For a one-off function with nothing to persist, Predicator.evaluate/3's
functions: option takes a %{name => {arity, fun}} map directly, without
building a context or a provider module first:
iex> custom_functions = %{"double" => {1, fn [n], _context -> {:ok, n * 2} end}}
iex> Predicator.evaluate("double(score) > 100", %{"score" => 60}, functions: custom_functions)
{:ok, true}A closure can read the evaluation context, not just its arguments -
context.data is the bound-variable map:
iex> custom_functions = %{"user_role" => {0, fn [], context -> {:ok, Map.get(context.data, "current_user_role", "guest")} end}}
iex> Predicator.evaluate("user_role() == 'admin'", %{"current_user_role" => "admin"}, functions: custom_functions)
{:ok, true}Errors from functions
A function that returns {:error, message} surfaces as an
EvaluationError, not a bare string - see the error shapes
reference. This applies equally to a
provider callback and an inline closure:
iex> custom_functions = %{"divide" => {2, fn [a, b], _context ->
...> if b == 0, do: {:error, "Division by zero"}, else: {:ok, a / b}
...> end}}
iex> Predicator.evaluate("divide(10, 2) == 5", %{}, functions: custom_functions)
{:ok, true}
iex> custom_functions = %{"divide" => {2, fn [a, b], _context ->
...> if b == 0, do: {:error, "Division by zero"}, else: {:ok, a / b}
...> end}}
iex> {:error, err} = Predicator.evaluate("divide(10, 0)", %{}, functions: custom_functions)
iex> {err.__struct__, err.message}
{Predicator.Errors.EvaluationError, "Division by zero"}Overriding builtins
A custom function - provider or inline closure - with the same name as a builtin overrides it for that evaluation only:
iex> override_functions = %{"len" => {1, fn [_], _context -> {:ok, "custom_result"} end}}
iex> Predicator.evaluate("len('anything')", %{}, functions: override_functions)
{:ok, "custom_result"}
iex> Predicator.evaluate("len('hello')", %{})
{:ok, 5}The full shadowing order, when both are given: the four builtin provider
modules, then providers: left to right, then functions: last - each step
shadowing a same-named entry from the step before it.
Function format
Both a provider's functions/0 and an inline functions: map share the same
value shape - {arity, callable} - and differ only in what callable is:
- Map key: function name (string)
- Map value:
{arity, callable}tuple where:arity: the number of arguments the function expects, as an integer - or as a list of integers for a function with optional arguments (substring/2and/3both register under"substring" => {[2, 3], :call_substring}in the builtin string functions, for example)callable: for a provider, an atom naming a public 2-arity function exported by that same module; for an inline map, an anonymous function taking[args], context. Either form returns{:ok, result}or{:error, message}
Custom functions carry no global state - they are scoped to the single
Context.new/2 or evaluate/3 call that receives them, so concurrent
evaluations with different function sets never interfere with each other.