Predicator.Context (predicator v7.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 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. value is stored verbatim, the same as bind/3 stores a scalar.

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), recursing through nested maps and lists; structs pass through unchanged. nil is preserved as-is - it is the null value, distinct from the :undefined sentinel; see docs/reference/language.md.

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" => nil}}

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), recursing through nested maps and lists. Date, DateTime, and any other struct pass through unchanged. nil is preserved as-is - it is the null value, distinct from the :undefined sentinel; see docs/reference/language.md.

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

Examples

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

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

iex> context = Predicator.Context.new(%{"x" => nil})
iex> {Predicator.Context.bound?(context, "x"), Predicator.evaluate("x === undefined", context)}
{true, {:ok, false}}

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).