StatifierUI.Expression (StatifierUI v0.4.0)

Copy Markdown View Source

The completion source behind the expression-editing component: predicator's own grammar vocabulary, plus the datamodel paths a host declares.

This module is pure and touches no LiveView, the same split StatifierUI.Live.State gives the ops panes. StatifierUI.Live.ExpressionInput renders what completions/2 returns; everything about what an author can be offered is decided here, where it is testable without a browser and without Phoenix.

Two sources, and only two

A completion is either a declared datamodel path - supplied by the caller, because only the host knows its own datamodel - or a lexeme of the predicator grammar, read from Predicator.Vocabulary (px-15q). Nothing is invented here. An operator this module offered that the lexer does not accept would be a second, drifting copy of the grammar, which is precisely the duplication Predicator.Vocabulary was published to prevent.

The grammar half degrades

Predicator.Vocabulary is newer than the predicator releases this package can resolve, so its absence is a supported state rather than a broken one: a host on an older predicator gets its declared paths and no grammar entries, and the component renders a plain input with a path list. The module is reached through Application.get_env(:statifier_ui, :predicator_vocabulary, Predicator.Vocabulary) and guarded with Code.ensure_loaded?/1, so nothing raises and nothing warns at compile time.

The picklist half

simple/2 answers a second question about a source string: not "what could be typed next" but "can a row of dropdowns draw this at all". It reads Predicator.Simple (px-84i), the upstream module that names the picklist-renderable subset, and returns the clause rows a renderer walks.

The three answers Predicator.Simple.from_source/1 keeps apart are kept apart here too, because an editor needs all three: source inside the subset, a valid expression outside it, and text that is not an expression at all. Collapsing the middle one into an error would tell an author their working condition is broken.

Predicator.Simple degrades exactly as Predicator.Vocabulary does, through Application.get_env(:statifier_ui, :predicator_simple, Predicator.Simple) and a Code.ensure_loaded?/1 guard. A host on an older predicator gets :outside for every source string, which is the answer that makes the component fall back to its plain text input.

Shape

Every completion is a map with four keys:

  • :label - what a completion list shows ("len(...)", "contains")
  • :insert - the text written into the source at the caret
  • :kind - "path", "function", or the px category of a lexeme ("comparison", "logical", ...), which is what a list groups by
  • :detail - one line of prose, or nil when the source carries none

Examples

iex> StatifierUI.Expression.completions(["order.total"])
...> |> Enum.find(&(&1.insert == "order.total"))
%{label: "order.total", insert: "order.total", kind: "path", detail: "declared path"}

Summary

Types

One offer in a value dropdown, as the host declared it.

One offer: what to show, what to write, how to group it.

The connective joining a picklist's clause rows.

One entry in an operator dropdown: the atom to build a clause with, the source spelling Predicator.Simple.to_source/1 writes for it, and the grammar's own one-line description when Predicator.Vocabulary is resolvable.

One row of the picklist: everything a renderer needs to draw a field / operator / value line, and nothing it would have to compute itself.

What kind of value a clause row holds, which is what decides its operator list and its value control.

Functions

Every completion available to an expression field: the declared paths first, then the grammar.

The subset a native <datalist> can usefully offer: the word-shaped completions.

The operators a picklist offers beside a value of the given kind.

The path segments a declared datamodel path parses to.

Classifies a source string against the picklist-renderable subset.

Whether the resolved predicator exposes Predicator.Simple.

Writes a source string back from the rows simple/2 returned.

The values a host offers for one clause path, normalized.

The source text one clause value is written as, on its own.

Whether the resolved predicator exposes Predicator.Vocabulary.

Types

candidate()

@type candidate() :: %{label: String.t(), value: term()}

One offer in a value dropdown, as the host declared it.

completion()

@type completion() :: %{
  label: String.t(),
  insert: String.t(),
  kind: String.t(),
  detail: String.t() | nil
}

One offer: what to show, what to write, how to group it.

connective()

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

The connective joining a picklist's clause rows.

nil for a single row, which is joined to nothing - the same invariant Predicator.Simple carries.

operator()

@type operator() :: %{op: atom(), label: String.t(), detail: String.t() | nil}

One entry in an operator dropdown: the atom to build a clause with, the source spelling Predicator.Simple.to_source/1 writes for it, and the grammar's own one-line description when Predicator.Vocabulary is resolvable.

row()

@type row() :: %{
  path: String.t(),
  segments: [tuple()],
  op: atom(),
  op_label: String.t(),
  value: term(),
  value_kind: value_kind(),
  value_source: String.t(),
  operators: [operator()],
  candidates: [candidate()]
}

One row of the picklist: everything a renderer needs to draw a field / operator / value line, and nothing it would have to compute itself.

:segments and :value are Predicator.Simple's own structural forms, kept so a renderer can hand an edited row straight back to Predicator.Simple.to_ast/1; :path, :op_label, and :value_source are the same three things spelled the way the source spells them.

value_kind()

@type value_kind() ::
  :integer
  | :boolean
  | :string
  | :date
  | :datetime
  | :duration
  | :relative_date
  | {:list, value_kind() | nil}

What kind of value a clause row holds, which is what decides its operator list and its value control.

A list carries the kind of its members, or nil when it is empty.

Functions

completions(candidates \\ [], opts \\ [])

@spec completions(
  [String.t()],
  keyword()
) :: [completion()]

Every completion available to an expression field: the declared paths first, then the grammar.

candidates is the declared datamodel path list - what StatifierBlocks.Datamodel.candidates/3 returns, arriving through the expression_component seam as :candidates. Paths lead because they are the ones an author cannot look up.

opts is passed through to Predicator.Vocabulary.functions/1, so a host with its own Predicator.FunctionProvider modules offers exactly the functions its own contexts will accept.

Examples

iex> StatifierUI.Expression.completions() |> Enum.any?(&(&1.insert == ">="))
true

iex> StatifierUI.Expression.completions([], builtins: false)
...> |> Enum.any?(&(&1.kind == "function"))
false

datalist(completions)

@spec datalist([completion()]) :: [completion()]

The subset a native <datalist> can usefully offer: the word-shaped completions.

A <datalist> filters its options against the whole field value, and it cannot insert at a caret. Offering "::" or "(" through one is noise, so the no-JavaScript affordance carries paths, keywords, and function names and leaves the symbol operators to the hook.

Examples

iex> StatifierUI.Expression.completions() |> StatifierUI.Expression.datalist()
...> |> Enum.any?(&(&1.insert == "::"))
false

operators(value_kind)

@spec operators(value_kind()) :: [operator()]

The operators a picklist offers beside a value of the given kind.

The atom is what a clause is built with and the label is the spelling Predicator.Simple.to_source/1 writes for it, so an author who picks an operator sees the same text the expression will carry. :detail is the grammar's own one-line description, or nil when Predicator.Vocabulary is not resolvable.

Empty when Predicator.Simple is absent, for the same reason simple/2 answers :outside: there is nothing truthful to offer.

Examples

iex> StatifierUI.Expression.operators(:boolean) |> Enum.map(& &1.op)
[:equal_equal, :ne]

iex> StatifierUI.Expression.operators({:list, :string}) |> Enum.map(& &1.label)
["IN"]

iex> StatifierUI.Expression.operators(:integer) |> Enum.map(& &1.label)
["==", "!=", ">", ">=", "<", "<="]

segments(path)

@spec segments(String.t()) :: {:ok, [tuple()]} | :error

The path segments a declared datamodel path parses to.

A picklist's field dropdown offers the paths a host declared, as strings. Swapping a clause onto one of them needs that path in the structural form a clause carries, and the only honest way to get there is predicator's own parser - a path split on dots here would read cart['items'] wrong and would be a second parser besides.

:error for a string that is not a path, and for every string when Predicator.Simple is not resolvable.

Examples

iex> StatifierUI.Expression.segments("card.brand")
{:ok, [root: "card", property: "brand"]}

iex> StatifierUI.Expression.segments("amount >= 500")
:error

simple(source, opts \\ [])

@spec simple(
  String.t(),
  keyword()
) :: {:ok, [row()], connective()} | :outside | {:error, term()}

Classifies a source string against the picklist-renderable subset.

Three answers, and they are three different questions:

  • {:ok, rows, connective} - inside the subset. rows is one row/0 per clause and connective is nil for a single row, :and or :or for two or more.
  • :outside - a valid expression a picklist cannot draw. Offer the text editor; this is not an error.
  • {:error, error} - the source does not parse, carrying predicator's own parse error with the position of the failure.

opts takes :value_candidates - a map from a clause's :path to the values a host offers for it, as candidate/0 maps or bare strings. Only the host knows its own value sets, so nothing is inferred here; a path with no entry gets an empty list and the renderer falls back to a free-text value control.

When the resolved predicator has no Predicator.Simple, every source string answers :outside. That is a degraded answer rather than a wrong one: the component renders the text input it would render for an unsupported expression.

Examples

iex> {:ok, [row], nil} = StatifierUI.Expression.simple("plan == 'pro'")
iex> {row.path, row.op, row.value_source}
{"plan", :equal_equal, "'pro'"}

iex> {:ok, rows, connective} = StatifierUI.Expression.simple("status == 'active' AND amount >= 500")
iex> {length(rows), connective}
{2, :and}

iex> StatifierUI.Expression.simple("status == 'active' AND (amount >= 500 OR plan == 'pro')")
:outside

iex> {:error, error} = StatifierUI.Expression.simple("amount >= >=")
iex> error.position
{1, 11}

iex> {:ok, [row], nil} =
...>   StatifierUI.Expression.simple("step in ['payment', 'review']",
...>     value_candidates: %{"step" => ["payment", "review", "confirmation"]}
...>   )
iex> {row.value_kind, Enum.map(row.candidates, & &1.value)}
{{:list, :string}, ["payment", "review", "confirmation"]}

simple_available?()

@spec simple_available?() :: boolean()

Whether the resolved predicator exposes Predicator.Simple.

The counterpart to vocabulary_available?/0, and the same distinction: a component stamps it so a host can tell "this expression is outside the subset" from "this predicator cannot answer the question".

Examples

iex> is_boolean(StatifierUI.Expression.simple_available?())
true

source(rows, connective)

@spec source([row() | {[tuple()], atom(), term()}], connective()) ::
  {:ok, String.t()} | :error

Writes a source string back from the rows simple/2 returned.

The write half of the same round trip: simple/2 reads source into rows and this reads rows back into source, both through Predicator.Simple, so a picklist never has to spell an operator, a quote, or a connective itself. That is what makes the rendered dropdowns a view of the source text rather than a second representation of the condition - a renderer edits a row's :segments, :op, or :value, asks for the source, and stores the string it gets back.

:error when Predicator.Simple is not resolvable, or when rows is empty: there is no source string to write, and inventing one would be the duplication this module exists to avoid.

Examples

iex> {:ok, rows, connective} =
...>   StatifierUI.Expression.simple("status == 'active' AND amount >= 500")
iex> StatifierUI.Expression.source(rows, connective)
{:ok, "status == 'active' AND amount >= 500"}

iex> {:ok, [row], nil} = StatifierUI.Expression.simple("plan == 'pro'")
iex> StatifierUI.Expression.source([%{row | value: {:string, "free", :single}}], nil)
{:ok, "plan == 'free'"}

iex> StatifierUI.Expression.source([], nil)
:error

value_candidates(candidates, path)

@spec value_candidates(
  %{optional(String.t()) => [candidate() | String.t()]},
  String.t()
) :: [
  candidate()
]

The values a host offers for one clause path, normalized.

candidates is simple/2's :value_candidates map. An entry may be a candidate/0 map or a bare string, because a declared path list is usually already a list of strings and making a caller wrap each one buys nothing.

simple/2 folds this into every row it returns. It is public because a renderer adding a new row has a path with no clause behind it yet, and still needs its value list.

Examples

iex> StatifierUI.Expression.value_candidates(%{"step" => ["payment", "review"]}, "step")
[%{label: "payment", value: "payment"}, %{label: "review", value: "review"}]

iex> StatifierUI.Expression.value_candidates(%{}, "plan")
[]

value_source(op, value)

@spec value_source(atom(), term()) :: {:ok, String.t()} | :error

The source text one clause value is written as, on its own.

source/2 covers every edit a renderer makes to a whole expression. This covers the one case it cannot: a control that has to compose a value - a free-text field that types into a quoted string, a multi-select that builds a list - needs the spellings of the pieces, and asking for them here keeps them coming from Predicator.Simple.to_source/1 rather than from a quoting rule written a second time in JavaScript.

op is the operator the value sits beside, because :in is the one operator whose right-hand side is a list.

Examples

iex> StatifierUI.Expression.value_source(:equal_equal, {:string, "pro", :single})
{:ok, "'pro'"}

iex> StatifierUI.Expression.value_source(:gte, {:integer, 500})
{:ok, "500"}

iex> StatifierUI.Expression.value_source(:in, {:list, [{:string, "payment", :single}]})
{:ok, "['payment']"}

vocabulary_available?()

@spec vocabulary_available?() :: boolean()

Whether the resolved predicator exposes Predicator.Vocabulary.

The component stamps this on the rendered input so a host - or a test - can tell "no grammar completions offered" from "grammar completions offered and none matched".

Examples

iex> is_boolean(StatifierUI.Expression.vocabulary_available?())
true