Predicator.Simple (predicator v9.2.0)

Copy Markdown View Source

The subset of the expression language a picklist-style editor can render.

A structured authoring surface - the row of dropdowns that reads field / operator / value, repeated down a form - cannot render an arbitrary expression. It has no place to put a parenthesis, no second precedence level, and no way to draw NOT. What it can render is a flat list of comparisons joined by one connective, and that is exactly what this module names as a value.

t/0 is that value: a list of clause/0s joined by one connective/0. A clause is {path, op, value} - a field path, a comparison or membership operator, and a literal (or a list of literals). Nothing else is in the subset.

iex> {:ok, simple} = Predicator.Simple.from_source("status == 'active' AND amount >= 500")
iex> simple.connective
:and
iex> Predicator.Simple.to_source(simple)
"status == 'active' AND amount >= 500"

Outside the subset

Everything the editor cannot draw answers :outside - a plain atom, not an error, because being outside the subset is an ordinary and expected answer about a perfectly valid expression. Mixed AND/OR, parentheses, NOT, arithmetic, function calls, casts, and object literals are all outside, by decision rather than by omission.

iex> Predicator.Simple.from_source("status == 'active' AND (amount >= 500 OR plan == 'pro')")
:outside

iex> Predicator.Simple.from_source("amount + 1 >= 500")
:outside

:outside is not a judgement about the expression. from_source/1 keeps {:error, error} for source that does not parse at all, so a caller can tell "this is a valid expression my editor cannot draw" from "this is not an expression".

iex> {:error, error} = Predicator.Simple.from_source("status == ==")
iex> error.position
{1, 11}

Round-tripping

Two laws hold for every well-formed t/0, and test/predicator/simple_test.exs exercises both over an enumerated corpus:

  • from_ast(to_ast(simple)) == {:ok, simple}
  • to_source(simple) parses to to_ast(simple), modulo source positions

The second law is what constrains the subset's edges. It is why the subset admits :equal_equal but not :eq, admits {:string_literal, value, style} but not a bare binary {:literal, value}, and admits only non-negative numbers - in each case the excluded shape decompiles to source that parses back as something else, so a value carrying it could not survive a trip through the editor. See "What the subset admits" below.

What the subset admits

A path/0 is an identifier, optionally followed by property and bracket accesses: status, card.brand, cart['items']. A bracket key is a string or a non-negative integer, never a computed expression.

A value/0 is one scalar or a list of scalars. A scalar is a non-negative integer, a boolean, a string (carrying the quote style it was written with), a date, a datetime, a duration, or a relative date.

Three exclusions are deliberate and each has a reason:

ExcludedWhy
Float literalsPredicator.decompile/2 has no clause for them and raises, so to_source/2 could not stay total (px-ggb)
Negative numbersThe parser reads -5 as a unary node, never as a negative literal, so a negative literal could not have come from a parse and does not survive one
:eq, and a bare binary {:literal, "text"}Both decompile to source that parses back as a different node (:equal_equal and :string_literal), breaking the source round-trip

Which operators an editor offers

operators/1 answers, for one kind of value, which operators a row of the form should offer. The answer is read from Predicator.Vocabulary - the labels, the arities, the AST atoms and the per-kind admissions all live on the operator entries there, so this module holds no second copy of the grammar and a change to the language reaches the editor without one.

iex> Predicator.Simple.operators(:list)
[%{op: :in, lexeme: "IN", label: "is one of", arity: 2}]

The lexeme is the spelling to_source/2 renders, not merely a spelling the lexer accepts: a word operator is enumerated in both cases (in and IN), and offering the one the decompiler does not write would put a label beside a row that renders differently the moment it is saved.

Purity

Nothing here evaluates, and nothing here raises on input. from_ast/1 is total over every Predicator.Parser.ast/0: the answer to a node it does not recognise is :outside, never an exception.

Summary

Types

One row of the editor: a field path, an operator, and a value.

The connective joining the clauses.

A comparison or membership operator, spelled as the AST spells it.

A field path: a root identifier and the accesses that follow it.

A single value in a clause.

One step along a field path.

t()

The subset value: clauses joined by one connective.

The right-hand side of a clause: one scalar, or a list of them.

Functions

The duration units a scalar/0 duration may use.

Reads a t/0 out of an AST, or answers :outside.

Parses source and reads a t/0 out of it.

The operators an editor should offer for a value of kind.

Builds the AST for a t/0.

Renders a t/0 back to source, through Predicator.decompile/2.

Answers whether a value satisfies the invariants to_ast/1 expects.

Types

clause()

@type clause() :: {path(), op(), value()}

One row of the editor: a field path, an operator, and a value.

connective()

@type connective() :: :and | :or | nil

The connective joining the clauses.

nil for a single clause, which is joined to nothing. A t/0 carrying one clause and a non-nil connective is not well-formed - see well_formed?/1.

op()

@type op() ::
  :gt
  | :gte
  | :lt
  | :lte
  | :equal_equal
  | :ne
  | :strict_eq
  | :strict_ne
  | :in
  | :contains

A comparison or membership operator, spelled as the AST spells it.

path()

@type path() :: [segment(), ...]

A field path: a root identifier and the accesses that follow it.

scalar()

@type scalar() ::
  {:integer, non_neg_integer()}
  | {:boolean, boolean()}
  | {:string, binary(), :single | :double}
  | {:date, Date.t()}
  | {:datetime, DateTime.t()}
  | {:duration, [{non_neg_integer(), binary()}, ...]}
  | {:relative_date, [{non_neg_integer(), binary()}, ...],
     :ago | :future | :next | :last}

A single value in a clause.

A string carries the quote style it was written with, so plan == 'pro' decompiles back to single quotes rather than switching house style under the author.

segment()

@type segment() ::
  {:root, binary()}
  | {:property, binary()}
  | {:key, binary() | non_neg_integer()}

One step along a field path.

The first segment of a path is always {:root, name} and no later segment ever is. A {:key, ...} segment is a bracket access and a {:property, ...} segment is a dotted one; the two are kept apart because cart['items'] and cart.items are different source text for the same lookup.

t()

@type t() :: %Predicator.Simple{clauses: [clause(), ...], connective: connective()}

The subset value: clauses joined by one connective.

:clauses is never empty. :connective is nil exactly when there is one clause.

value()

@type value() :: scalar() | {:list, [scalar()]}

The right-hand side of a clause: one scalar, or a list of them.

Functions

duration_units()

@spec duration_units() :: [binary()]

The duration units a scalar/0 duration may use.

Read from Predicator.Vocabulary, which is the grammar's single enumerated source (px-15q), so a unit added to the lexer reaches this subset without a second list to update by hand.

Examples

iex> "d" in Predicator.Simple.duration_units()
true

from_ast(ast)

@spec from_ast(Predicator.Parser.ast()) :: {:ok, t()} | :outside

Reads a t/0 out of an AST, or answers :outside.

Total: every Predicator.Parser.ast/0 gets an answer, and an AST outside the subset gets :outside rather than an exception. Source positions are ignored, so a hand-built node carrying nil reads the same as a parsed one.

Examples

iex> {:ok, ast} = Predicator.parse("amount >= 500")
iex> Predicator.Simple.from_ast(ast)
{:ok, %Predicator.Simple{connective: nil, clauses: [{[root: "amount"], :gte, {:integer, 500}}]}}

iex> {:ok, ast} = Predicator.parse("NOT plan == 'pro'")
iex> Predicator.Simple.from_ast(ast)
:outside

from_source(source)

@spec from_source(binary()) ::
  {:ok, t()} | :outside | {:error, Predicator.Errors.ParseError.t()}

Parses source and reads a t/0 out of it.

Adds one arm to from_ast/1's two: {:error, error} for source that does not parse, carrying a Predicator.Errors.ParseError.t/0 with the position and span of the failure (ADR-0015). The three arms answer three different questions - in the subset, a valid expression outside it, and not an expression - and a caller that collapses any two of them loses information an editor needs.

Examples

iex> Predicator.Simple.from_source("step in ['payment', 'review']")
{:ok,
 %Predicator.Simple{
   connective: nil,
   clauses: [{[root: "step"], :in, {:list, [{:string, "payment", :single}, {:string, "review", :single}]}}]
 }}

iex> Predicator.Simple.from_source("len(step) > 0")
:outside

iex> {:error, %Predicator.Errors.ParseError{}} = Predicator.Simple.from_source("step in [")
iex> :ok
:ok

operators(kind)

@spec operators(Predicator.Vocabulary.value_kind()) :: [
  %{op: op(), lexeme: binary(), label: binary(), arity: 2}
]

The operators an editor should offer for a value of kind.

One entry per operator, in the order Predicator.Vocabulary enumerates them, carrying everything a picklist row needs: :op is the atom to put in a clause/0, :lexeme is the spelling to_source/2 renders, :label is the phrase to show, and :arity is how many operands the operator takes - always 2 here, since every operator in the subset is a comparison or a membership test.

Nothing is enumerated locally. The admissions are the :value_kinds on the vocabulary's operator entries and the atoms are its :ast_ops, so an operator this subset does not admit, or one the grammar does not admit for this kind of value, is never offered - and neither is one the lexer would reject. test/predicator/simple_test.exs holds that as an invariant over every kind.

kind is guarded against Predicator.Vocabulary.value_kinds/0, so a misspelled kind raises FunctionClauseError rather than answering with the empty list that a kind with no operators would answer with.

Examples

iex> Predicator.Simple.operators(:boolean) |> Enum.map(& &1.op)
[:equal_equal, :strict_eq, :ne, :strict_ne, :contains]

iex> Predicator.Simple.operators(:number) |> Enum.find(&(&1.op == :gte))
%{op: :gte, lexeme: ">=", label: "is at least", arity: 2}

iex> Predicator.Simple.operators(:list) |> Enum.map(& &1.lexeme)
["IN"]

to_ast(simple)

@spec to_ast(t()) :: Predicator.Parser.ast()

Builds the AST for a t/0.

Every node carries nil in its trailing position slot - the value records what the expression means, not where it was written. Clauses are joined left-associatively, which is the shape Predicator.Parser.parse/2 produces for the same source.

Expects a well-formed value; see well_formed?/1.

Examples

iex> {:ok, simple} = Predicator.Simple.from_source("plan == 'pro'")
iex> Predicator.Simple.to_ast(simple)
{:comparison, :equal_equal, {:identifier, "plan", nil}, {:string_literal, "pro", :single, nil}, nil}

to_source(simple, opts \\ [])

@spec to_source(
  t(),
  keyword()
) :: binary()

Renders a t/0 back to source, through Predicator.decompile/2.

opts are that function's formatting options - :parentheses and :spacing - so a caller that renders the rest of its expressions one way renders these the same way.

Examples

iex> {:ok, simple} = Predicator.Simple.from_source("status == 'active' AND amount >= 500")
iex> Predicator.Simple.to_source(simple)
"status == 'active' AND amount >= 500"

iex> {:ok, simple} = Predicator.Simple.from_source("plan == 'pro'")
iex> Predicator.Simple.to_source(simple, spacing: :compact)
"plan=='pro'"

well_formed?(arg1)

@spec well_formed?(term()) :: boolean()

Answers whether a value satisfies the invariants to_ast/1 expects.

Every value from_ast/1 returns is well-formed; this function is for values a caller assembled itself - an editor building one from form state. The invariants are that :clauses is a non-empty list of structurally valid clauses, and that :connective is nil exactly when there is one clause.

Examples

iex> {:ok, simple} = Predicator.Simple.from_source("amount >= 500")
iex> Predicator.Simple.well_formed?(simple)
true

iex> Predicator.Simple.well_formed?(%Predicator.Simple{connective: :and, clauses: [{[root: "amount"], :gte, {:integer, 500}}]})
false