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 non-negative float, a boolean, a string (carrying the quote style
it was written with), a date, a datetime, a duration, or a relative date.
Two exclusions are deliberate and each has a reason:
| Excluded | Why |
|---|---|
| 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 |
Float literals were a third exclusion until px-gv1. It was contingent, not
structural: Predicator.decompile/2 had no clause for a float and raised,
so to_source/2 could not have stayed total. px-ggb gave the writer that
clause, so the reason went and the exclusion went with it.
Integers and floats are one kind, and two shapes
A float is its own scalar/0 shape, {:float, 19.99}, because the AST
literal it builds differs from an integer's and the round-trip laws have to
preserve which one an author wrote.
iex> Predicator.Simple.from_source("card.amount >= 19.99")
{:ok, %Predicator.Simple{connective: nil, clauses: [{[root: "card", property: "amount"], :gte, {:float, 19.99}}]}}It is not its own Predicator.Vocabulary.value_kind/0. For choosing an
operator both are :number, since every operator worth offering beside
19.99 is one worth offering beside 500. See operators/1.
Which operators an editor offers
operators/1 answers, for one kind of value, which operators a row of the
form should offer. value_kind/1 answers which kind a value is, so
the pair composes into the whole question a row asks and a consumer keeps
no translation of its own. 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.
The Predicator.Vocabulary.value_kind/0 that governs value.
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()} | {:float, float()} | {: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, and nothing about the vocabulary is re-derived
here. The admissions are the :value_kinds on the vocabulary's operator
entries, the atoms are its :ast_ops, and the choice between the two
spellings of a word operator is its :canonical marker - 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 the lower-case spelling of one
to_source/2 would render in upper case.
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. :number is the
kind for an integer and for a float alike - there is no :float kind to ask
for, by the decision recorded above.
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'"
@spec value_kind(value()) :: Predicator.Vocabulary.value_kind()
The Predicator.Vocabulary.value_kind/0 that governs value.
operators/1 answers which operators belong beside a kind of value.
This answers which kind a value is, so the two compose into the whole
question an editor row asks - value |> value_kind() |> operators() - and
no consumer has to keep its own translation from the subset's scalar tags
to the vocabulary's kinds. Two consumers writing that translation
separately could disagree about the same value; there is one answer here
instead, and it is this package's.
The mapping is not one-to-one, and the two places it is not are decisions rather than omissions.
An integer and a float are two scalar/0 shapes and one kind, :number,
for the reason recorded above: every operator worth offering beside 19.99
is one worth offering beside 500.
iex> Predicator.Simple.value_kind({:integer, 500})
:number
iex> Predicator.Simple.value_kind({:float, 19.99})
:numberA relative date is :datetime. There is no :relative_date kind and this
is not a gap left for a consumer to judge: 30d ago is a point in time by
the time anything compares it - Predicator.Evaluator resolves it against
DateTime.utc_now/0 and pushes a DateTime.t/0 - so the operators that
apply to it are the datetime operators, and a kind of its own would name
the same list under a second name.
iex> Predicator.Simple.value_kind({:relative_date, [{30, "d"}], :ago})
:datetime
iex> Predicator.Simple.operators(:datetime) == Predicator.Simple.operators(:date)
trueA list is :list whatever its members are, which is what makes IN the
operator offered for it.
iex> Predicator.Simple.value_kind({:list, [{:string, "payment", :single}]})
:list
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