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 toto_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:
| Excluded | Why |
|---|---|
| Float literals | Predicator.decompile/2 has no clause for them and raises, so to_source/2 could not stay total (px-ggb) |
| Negative numbers | The 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.
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
One row of the editor: a field path, an operator, and a value.
@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.
@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.
@type path() :: [segment(), ...]
A field path: a root identifier and the accesses that follow it.
@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.
@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.
@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.
The right-hand side of a clause: one scalar, or a list of them.
Functions
@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
@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
@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
@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"]
@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}
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'"
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