Predicator.Context (predicator v5.0.0)

Copy Markdown View Source

A bound evaluation context: data, functions, and the unbound-variable policy.

Build one with new/2, evaluate Predicator.evaluate/3 against it many times, and rebind cheaply with bind/3 between evaluations - functions resolve once, at construction, not on every evaluate call.

Functions

functions is a closed dispatch map, resolved once by new/2 from three sources, folded left so a later one shadows an earlier same-named entry: the four builtin provider modules (:builtins, default true), then :providers - a list of Predicator.FunctionProvider modules, left to right - then :functions, an inline %{name => {arity, fun}} closure map merged last. A provider module is validated at construction (ArgumentError on a bad one - host API misuse, not a predicate-derived failure); a resolved entry is either {arity, {module, atom}} or {arity, fun}, and the evaluator dispatches both the same way, handing the function (args, context).

The host slot

host is an opaque carrier for whatever a function provider needs at call time - a database connection, a request struct, a tenant id. It is stored exactly as given, with no normalization: unlike data, atom keys and nil values inside a host term are never touched. It is never readable from predicate text - there is no syntax that reaches it - and it is never merged into data. Set it with new/2's :host option or put_host/2.

Examples

iex> context = Predicator.Context.new(%{"score" => 85})
iex> Predicator.evaluate("score > 80", context)
{:ok, true}

iex> context = Predicator.Context.new(%{})
iex> context = Predicator.Context.bind(context, "score", 90)
iex> Predicator.evaluate("score", context)
{:ok, 90}

Summary

Types

A function entry the evaluator can dispatch: an MFA pair or a closure.

Policy for a load of an unbound root variable.

t()

A bound evaluation context.

Functions

Assigns value at path_or_expression, returning the rebound context.

Binds name to value in data. O(1): a single Map.put/3, plus normalizing value itself (O(size of value), not O(size of data) - data is already normalized from construction or a prior bind/3).

Answers whether name is bound in context's data, resolved by Predicator.Evaluator.resolve_key/2 - the same lookup the evaluator uses when a load instruction records an unbound read, so the two cannot disagree. Presence, not definedness: a name bound to :undefined is bound.

Builds a context, resolving its function dispatch map once.

Replaces context's host term, leaving data, functions, and on_unbound untouched. The new host is stored exactly as given - no normalization, same as new/2's :host option.

Resolves opts's :builtins, :providers, and :functions into a single dispatch map - the same resolution new/2 performs internally, exposed so Predicator.Evaluator.evaluate/3 can route its own :functions/ :providers/:builtins opts through it without going through the rest of new/2 (data normalization, :on_unbound validation, which evaluate/3 deliberately does not enforce).

Types

function_entry()

@type function_entry() :: {module(), atom()} | function()

A function entry the evaluator can dispatch: an MFA pair or a closure.

on_unbound()

@type on_unbound() :: :undefined | :error

Policy for a load of an unbound root variable.

:undefined (the default) pushes the :undefined sentinel and lets three-valued logic absorb it. :error makes the load fail with Predicator.Errors.UndefinedVariableError, halting the run.

Roots only, under either policy: a missing key on a bound map (user.nope), a missing nested path, and an out-of-range index all stay :undefined. This mirrors ECMAScript - a ReferenceError for an undeclared variable, a silent undefined for a missing property - and keeps guards over sparse data usable.

t()

@type t() :: %Predicator.Context{
  data: Predicator.Types.context(),
  functions: %{
    required(binary()) =>
      {Predicator.Evaluator.function_arity(), function_entry()}
  },
  host: term(),
  on_unbound: on_unbound()
}

A bound evaluation context.

Functions

assign(context, path_or_expression, value)

@spec assign(
  t(),
  binary() | Predicator.ContextLocation.location_path(),
  Predicator.Types.value()
) ::
  {:ok, t()} | {:error, struct()}

Assigns value at path_or_expression, returning the rebound context.

path_or_expression is either a location expression string (resolved against the context's current data, then written) or an already-resolved Predicator.ContextLocation.location_path/0. Writes through Predicator.ContextLocation.put/3 - the same auto-vivifying write algorithm as Predicator.context_assign/4 and the future store opcode (px-tbv.2). functions, on_unbound, and host are carried over unchanged.

Examples

iex> context = Predicator.Context.new(%{})
iex> {:ok, context} = Predicator.Context.assign(context, "user.name", "Ada")
iex> context.data
%{"user" => %{"name" => "Ada"}}

iex> context = Predicator.Context.new(%{"items" => [1, 2, 3]})
iex> {:ok, context} = Predicator.Context.assign(context, ["items", 1], "x")
iex> context.data
%{"items" => [1, "x", 3]}

bind(context, name, value)

@spec bind(t(), binary(), Predicator.Types.value()) :: t()

Binds name to value in data. O(1): a single Map.put/3, plus normalizing value itself (O(size of value), not O(size of data) - data is already normalized from construction or a prior bind/3).

value is normalized the same way new/2 normalizes data: atom keys become string keys (string keys win on collision) and nil becomes :undefined, recursing through nested maps and lists; structs pass through unchanged.

functions, on_unbound, and host are carried over unchanged.

Examples

iex> context = Predicator.Context.new(%{"a" => 1})
iex> Predicator.Context.bind(context, "b", 2).data
%{"a" => 1, "b" => 2}

iex> context = Predicator.Context.new(%{})
iex> Predicator.Context.bind(context, "user", %{name: nil}).data
%{"user" => %{"name" => :undefined}}

bound?(context, name)

@spec bound?(t(), binary()) :: boolean()

Answers whether name is bound in context's data, resolved by Predicator.Evaluator.resolve_key/2 - the same lookup the evaluator uses when a load instruction records an unbound read, so the two cannot disagree. Presence, not definedness: a name bound to :undefined is bound.

resolve_key/2 also accepts an atom key, but a Context's data never has one: new/2 and bind/3 normalized them to string keys already. The %{score: 85} example below is bound because of that normalization, not because of an atom lookup here.

Examples

iex> context = Predicator.Context.new(%{"score" => 85})
iex> Predicator.Context.bound?(context, "score")
true
iex> Predicator.Context.bound?(context, "missing")
false

iex> context = Predicator.Context.new(%{score: 85})
iex> Predicator.Context.bound?(context, "score")
true

new(data \\ %{}, opts \\ [])

@spec new(
  Predicator.Types.context(),
  keyword()
) :: t()

Builds a context, resolving its function dispatch map once.

Parameters

  • data - the bound-variable map (default %{})
  • opts:
    • :builtins - true (default) includes the four builtin provider modules; false drops them, leaving only :providers and :functions
    • :providers - a list of Predicator.FunctionProvider modules, resolved left to right (a later module shadows an earlier one's same-named entry), after the builtins and before :functions. A module that fails to load, does not export functions/0, or names an atom not exported at arity 2 raises ArgumentError, naming the module and the offending entry
    • :functions - an inline %{name => {arity, fun}} closure map, merged last (shadows both builtins and :providers) - same as Predicator.evaluate/3's :functions option
    • :on_unbound - :undefined (default) | :error - see on_unbound/0; any other value raises ArgumentError

    • :host - default nil - see the "The host slot" section above

data is normalized deeply before it is stored: atom keys become string keys (a string key wins if both are present at the same level) and nil values become the :undefined sentinel, recursing through nested maps and lists. Date, DateTime, and any other struct pass through unchanged.

host, by contrast, is stored exactly as given - no normalization, atom keys and nils intact.

Examples

iex> Predicator.Context.new(%{"x" => 1}).data
%{"x" => 1}

iex> Predicator.Context.new(%{user: %{name: nil}}).data
%{"user" => %{"name" => :undefined}}

iex> Predicator.Context.new().on_unbound
:undefined

iex> context = Predicator.Context.new(%{}, on_unbound: :error)
iex> {:error, error} = Predicator.evaluate("missing OR true", context)
iex> {error.variable, error.position}
{"missing", {1, 1}}

iex> Predicator.Context.new(%{}, builtins: false).functions
%{}

put_host(context, host)

@spec put_host(t(), term()) :: t()

Replaces context's host term, leaving data, functions, and on_unbound untouched. The new host is stored exactly as given - no normalization, same as new/2's :host option.

Examples

iex> context = Predicator.Context.new(%{})
iex> Predicator.Context.put_host(context, %{conn: :db}).host
%{conn: :db}

resolve_functions(opts \\ [])

@spec resolve_functions(keyword()) :: %{
  required(binary()) =>
    {Predicator.Evaluator.function_arity(), function_entry()}
}

Resolves opts's :builtins, :providers, and :functions into a single dispatch map - the same resolution new/2 performs internally, exposed so Predicator.Evaluator.evaluate/3 can route its own :functions/ :providers/:builtins opts through it without going through the rest of new/2 (data normalization, :on_unbound validation, which evaluate/3 deliberately does not enforce).

Order: the builtin providers (Predicator.FunctionProvider.builtin_providers/0, unless builtins: false), then opts[:providers] left to right, then opts[:functions] merged last - each later source shadows a same-named entry from an earlier one.

Raises ArgumentError under the same conditions new/2 does, for the same reason: a bad provider module is host API misuse, not a predicate-derived failure (ADR-0004).