Predicator.Context (predicator v9.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
    • :normalize - true (default) | false - see the "The :normalize option" section below; any other value raises ArgumentError

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. Passing normalize: false skips this walk entirely and stores data exactly as given - the caller vouches that the invariant it would have established already holds.

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

The :normalize option

:normalize is true by default, which is what runs the data walk described above. Passing normalize: false skips it and stores data exactly as given - a caller-vouches option: the caller asserts that data already satisfies the normalization invariant (string keys throughout, at every level, with any nested map or list already normalized the same way) and accepts that violating it is the caller's own bug, not a defect here. A context built this way behaves identically to a normalized one as long as the invariant actually holds; if it does not, lookups keyed on a string that was never converted from an atom simply miss, the same silent :undefined a genuinely-unbound name produces.

This is safe to offer because it asks for nothing bind/3 does not already assume: bind/3's O(1) claim rests on "data is already normalized from construction" (see bind/3 below), so a caller vouching for that at construction time is upholding the same invariant bind/3 has relied on all along, just one call earlier.

The walk this skips is the entire size-scaling term of a build, so what it is worth depends on the shape of data: over a handful of flat scalar roots it is a small slice of the total, and over a large nested datamodel it is very nearly all of it. bench/results/260814-context-build.md carries the measured decomposition across three sizes; it is not transcribed here, because a figure in a moduledoc goes stale and the results file does not (px-10u; see also px-rnc for the fixed-term counterpart, memoized as of that bead - which also means the walk is a larger share of what remains). A caller that already guarantees the invariant - for example because its data model uses string keys natively and its own preprocessing already normalized nested structures - pays that cost for no benefit.

Performance

A build pays two costs, and they scale differently. The normalization walk described above is proportional to the size of data - normalize: false removes it (px-10u; see the :normalize section above). The other cost is fixed: resolving :builtins and :providers into the dispatch map, which does not shrink as data gets smaller and does not grow as it gets larger.

That fixed term is memoized as of px-rnc: a new/2 call against a provider list already seen in this process reuses the cached resolution instead of re-validating it, so repeat builds against the same provider list no longer re-pay validation. A build is still a build, though - the memo removes re-validation, not the allocation and struct construction new/2 does on every call.

The anti-pattern this leaves is calling new/2 once per evaluation. Hold one context and rebind instead: bind/3 is O(1) in the size of data, and put_host/2 is a plain struct field update - both exist so a caller never has to pay new/2's cost per evaluation, which is what ADR-0014's design is for. See bench/context_build.exs and its results file, bench/results/260814-context-build.md, for the actual numbers, rather than a figure transcribed here that would go stale.

Examples

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

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

iex> Predicator.Context.new(%{"user" => %{name: "Ada"}}, normalize: false).data
%{"user" => %{name: "Ada"}}

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