StatifierBlocks.Predicates (StatifierBlocks v0.4.0)

Copy Markdown View Source

The evaluation seam: a condition source string plus a binding context in, {:ok, boolean()} | {:error, reason()} out, through predicator.

Why Predicates, not Fixtures

ADR-0002 decision 9 (docs/adr/0002-block-type-behaviour.md:250) puts the fixture-bundle convention in statifier-ui (StatifierUI.Fixtures.Bundle) and says in as many words that this package does not invent a competing one. A module named StatifierBlocks.Fixtures would assert a contract this package does not own. Predicates names what this code actually is - the evaluation seam - and claims nothing about how a host packages a dataset.

The error vocabulary

evaluate/2 classifies four predicator outcomes into a closed set of tagged tuples. Errors are events: nothing here is coerced to a default.

Predicator returnsevaluate/2 returnsWhy
{:ok, true} / {:ok, false}{:ok, boolean()}the only success
{:ok, :undefined}{:error, {:undefined_result, source}}:undefined is predicator's absence sentinel, distinct from nil and from false. A cross-type comparison and an unbound operand (with on_unbound: :undefined) both produce it. Folding it to false would be the rescue-to-default the conventions forbid
{:ok, value}, not a boolean{:error, {:non_boolean, value}}"transaction.amount" is valid predicator source that evaluates to 120. A truthiness rule would be a second semantics this package invented on top of predicator's
{:error, %ParseError{}}{:error, {:parse_error, e}}the source is not predicator source at all
{:error, %UndefinedVariableError{variable: v}}{:error, {:undefined_variable, v, e}}the source is fine and the context is incomplete; the variable name is lifted out so a caller does not have to know predicator's struct
any other predicator error struct{:error, {:evaluation_error, e}}a total fallback, so a predicator version that adds an error struct returns an event rather than raising a FunctionClauseError

Two further tags belong to the binding layer (context/1), not to evaluate/2:

TagMeaning
{:binding, path, reason}a binding's source text did not produce a value; reason is one of the tags above
{:binding_conflict, path}two dotted paths cannot both be nested - "transaction" and "transaction.amount" bound together, or a duplicate

Durations are 15m, not PT15M

Block config stores whichever spelling the author typed, and the primary one is the predicator string ("15m", see StatifierBlocks.Core.Wait); ISO-8601 stays accepted there. A binding's source text here is predicator source, not config, and predicator's duration literal is 15m / 1h30m - its own lexer grammar, unrelated to ISO-8601. So the two agree on the spelling an author reaches for, but only one of them accepts the other: writing "PT15M" as a binding does not raise; it parses as a bare identifier and fails with {:undefined_variable, "PT15M", _}, which reads like a bug if this is not known going in.

See StatifierBlocks.Predicates.TruthTable for the truth-table builder that composes evaluate/2 and context/1 over an ordered set of branch arms, with first-match-wins selection.

Where this is not wired

ADR-0005 decision 9 (docs/adr/0005-liveview-editor.md:336) ships :expression as a plain source input and hands rich expression editing, including inline evaluation against a dataset, to statifier-ui, with a host-supplied override component as the seam. ADR-0005 decision 15 (:567) lists a per-palette-entry fixtures pane among the things the record explicitly does not decide, and the shipped editor (lib/statifier_blocks/editor.ex) has no fixtures pane and no assign or event a truth table would attach to. So this module is reached either directly by a host, or through statifier-ui's richer expression component - never through a LiveView file in this package.

The :evaluation_error fallback is reachable, not dead code

Probed live against predicator 9.0.1 (mix run, empty context): "true + 1" raises a Predicator.Errors.TypeMismatchError and "1 / 0" raises a Predicator.Errors.EvaluationError, both from ordinary binary source. The fallback clause is therefore proven reachable and is kept as a single catch-all clause after the ParseError and UndefinedVariableError clauses, tested against "true + 1".

Summary

Functions

Folds a %{dotted_path => source_text} map into a nested string-keyed context map. Each source is evaluated through evaluate_value/1; a failure becomes {:error, {:binding, path, reason}}. Paths are processed in sorted order so the error reported for a map with several bad bindings is deterministic. A path that would nest under, or over, an already-bound non-map value is {:error, {:binding_conflict, path}}.

Evaluates source as a predicator condition against context, classifying the result per the error vocabulary above. context defaults to %{}.

Evaluates source as a predicator value expression against an empty context. Returns any Predicator.Types.value() except :undefined, which is an error - a binding source has to resolve to a real value.

Types

context()

@type context() :: %{optional(String.t()) => term()}

reason()

@type reason() ::
  {:undefined_result, String.t()}
  | {:non_boolean, term()}
  | {:parse_error, struct()}
  | {:undefined_variable, String.t(), struct()}
  | {:evaluation_error, struct()}
  | {:binding, String.t(), reason()}
  | {:binding_conflict, String.t()}

Functions

context(bindings)

@spec context(%{optional(String.t()) => String.t()}) ::
  {:ok, context()} | {:error, reason()}

Folds a %{dotted_path => source_text} map into a nested string-keyed context map. Each source is evaluated through evaluate_value/1; a failure becomes {:error, {:binding, path, reason}}. Paths are processed in sorted order so the error reported for a map with several bad bindings is deterministic. A path that would nest under, or over, an already-bound non-map value is {:error, {:binding_conflict, path}}.

evaluate(source, context \\ %{})

@spec evaluate(String.t(), context()) :: {:ok, boolean()} | {:error, reason()}

Evaluates source as a predicator condition against context, classifying the result per the error vocabulary above. context defaults to %{}.

Argument order mirrors Predicator.evaluate(input, context). The repo convention that puts a state/session first is about threading a %Document{} or a socket through a pipeline; a plain binding context is a value, not a session, so this stays aligned with the wrapped library's own order instead of diverging for its own sake.

evaluate_value(source)

@spec evaluate_value(String.t()) :: {:ok, term()} | {:error, reason()}

Evaluates source as a predicator value expression against an empty context. Returns any Predicator.Types.value() except :undefined, which is an error - a binding source has to resolve to a real value.