StatifierUI.Expression (StatifierUI v0.9.1)

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 is resolved the way Predicator.Vocabulary is, through Application.get_env(:statifier_ui, :predicator_simple, Predicator.Simple), but the guard on it is wider than a Code.ensure_loaded?/1: simple_available?/0 also requires the resolved module to export from_source/1, to_source/1, operators/1 and value_kind/1. A host on an older predicator - or one that points that key at a module missing any of the four - degrades every function this guard gates, exactly as simple_available?/0's own documentation lists them: simple/2 answers :outside for every source string, operators/1 answers [], and source/2, value_source/2 and segments/1 answer :error. That 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(["card.brand"])
...> |> Enum.find(&(&1.insert == "card.brand"))
%{label: "card.brand", insert: "card.brand", kind: "path", detail: "declared path"}

Summary

Types

Something worth telling an author about a clause, and never a reason to refuse one.

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.

A kind a host declares for a datamodel path, as the :path_types map carries it: one of this module's own value_kind/0s, :number, or {:one_of, values} for a path whose values the host enumerates.

One entry in an operator dropdown, as Predicator.Simple.operators/1 answers it.

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, read from Predicator.Simple.operators/1.

The relative dates offered beside a path a host declared :date or :datetime.

The path segments a declared datamodel path parses to.

Classifies a source string against the picklist-renderable subset.

Whether the resolved predicator exposes a usable 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

advisory()

@type advisory() :: %{
  severity: :info,
  reason: :value_kind | :operator,
  message: String.t()
}

Something worth telling an author about a clause, and never a reason to refuse one.

:reason is :value_kind when the value in the source is not of the kind the host declared for the path, read through the operator, and :operator when the operator in the source is one the declared kind's list does not offer - which is the very condition that keeps it offered anyway. :contains never raises the second one: it takes its collection on the left, so the path's declared kind says nothing about whether it belongs.

An advisory is :info and only :info. ADR-0007 rules out refusing or rewriting an author's expression, so a declaration that disagrees with the source is reported and nothing else.

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.

declared_kind()

@type declared_kind() :: value_kind() | :number | {:one_of, [candidate() | term()]}

A kind a host declares for a datamodel path, as the :path_types map carries it: one of this module's own value_kind/0s, :number, or {:one_of, values} for a path whose values the host enumerates.

{:one_of, _} and :number are the caller's tags rather than grammar kinds

For {:one_of, _} the operators offered beside one are the operators of the kind its own values are: binaries are :string, integers :integer, floats :float, booleans :boolean, and an empty or mixed list falls back to :string. Inferring from the host's own values keeps that judgement to arithmetic over data the host supplied, rather than to a table about the grammar.

:number is StatifierDatamodel.Index.path_types/1's own tag: a datamodel document declares integer and decimal and the projection answers :number for both, because the expression language draws no line between them either. It is admitted here rather than translated at the seam so that the projection's map is a :path_types map as it stands - the same map, whether a host hands over its document or the projection of it. Nothing else changes: :number is already the grammar's own word, so it asks for the same operators :integer and :float ask for, and it compares equal to both.

operator()

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

One entry in an operator dropdown, as Predicator.Simple.operators/1 answers it.

:op is the atom a clause is built with and :lexeme is the source spelling Predicator.Simple.to_source/1 writes for it - the two halves that have to agree, and the reason a picklist can offer an operator without spelling one itself. :label is the grammar's own display phrase ("is at least" for ">="), a UI string that is never stored, and :detail its one-line description, or nil when Predicator.Vocabulary is not resolvable.

row()

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

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.

:op_label is therefore a source spelling, which is the one place in this module where "label" means the opposite of what it means next door: on operator/0 :label is the grammar's display phrase and :lexeme is the spelling. A dropdown draws operator/0's :label; :op_label is what the expression carries.

:value_kind is what the value in the source is; :declared_kind is what the host said the path holds, or nil when it said nothing; and :control_kind is the one a renderer picks a control from. The three are kept apart because they answer different questions and can disagree: a declaration decides which operators are offered, but it never decides the shape of a control, because a control that wrote a shape the source does not have would rewrite the author's expression. When they disagree, :advisories says so and nothing is changed.

value_kind()

@type value_kind() ::
  :integer
  | :float
  | :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.

These are the shapes a clause value takes, which is not the vocabulary Predicator.Vocabulary.value_kinds/0 names: it has one :number where this has :integer and :float, and no :relative_date at all. operators/1 translates between the two, and nothing else needs to.

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, read from Predicator.Simple.operators/1.

Eligibility is the grammar's answer, not this module's: which operators are worth offering beside a date as opposed to a number is decided by the :value_kinds predicator stamps on its own operator entries, so an operator the lexer would reject - or one the grammar does not admit for this kind of value - cannot be offered here. Until px-84i landed that function there was a table here instead, and ADR-0007's 2026-09-04 amendment - accepted 2026-09-05 - named it as the one local exception; delegating closes it.

:op builds the clause, :lexeme is the spelling the expression will carry, :label is the display phrase, and :detail is the grammar's one-line description, or nil when Predicator.Vocabulary is not resolvable.

Empty when simple_available?/0 is false, for the same reason simple/2 answers :outside: there is nothing truthful to offer. An atom the @spec does not admit is handed to upstream unchanged rather than rejected here, so one that happens to name a Predicator.Vocabulary kind - :number, :list - gets the grammar's answer for that kind, and any other atom raises there. The raise is upstream's own stance and the right one: an empty list would say "no operators here" about a kind that does not exist.

Examples

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

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

iex> StatifierUI.Expression.operators(:integer) |> Enum.find(&(&1.op == :gte))
%{op: :gte, lexeme: ">=", label: "is at least", detail: "Greater than or equal to"}

relative_date_candidates()

@spec relative_date_candidates() :: [candidate()]

The relative dates offered beside a path a host declared :date or :datetime.

A date declaration carries no value list of its own the way {:one_of, _} does, and a date field with only a free-text control asks an author to spell a grammar they should not have to know. So a small fixed set is offered instead. Which dates those are is presentation, which ADR-0007's picklist amendment leaves to the component rather than to the record; the set lives here rather than in the component so it is testable without a browser, and a host wanting different ones supplies :value_candidates for the path, which wins.

Every entry is checked against Predicator.Simple.duration_units/0 before it is offered, so a unit the resolved grammar does not know is dropped rather than written into a source string the writer would refuse.

Empty when simple_available?/0 is false, and empty when the resolved module predates duration_units/0 - that function is deliberately not added to simple_available?/0's four-export guard, because a predicator that can classify and write should lose the offered dates, not the whole picklist.

Examples

iex> StatifierUI.Expression.relative_date_candidates() |> Enum.map(& &1.label)
["1d ago", "7d ago", "30d ago", "90d ago", "7d from now", "30d from now"]

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 account['tags'] 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.

opts also takes :path_types - a map from a clause's :path to the declared_kind/0 the host declares for it. A declaration decides which operators the row offers, asked of the grammar exactly as an observed kind is; it fills the candidate list for a {:one_of, _} path that :value_candidates says nothing about, and offers relative_date_candidates/0 for a :date or :datetime one. It never decides a control whose shape the source does not have, and it never rewrites, coerces, or refuses anything: a declaration the source disagrees with lands in :advisories. A path with no entry behaves exactly as it did before there was a map at all.

Only the host knows its own datamodel, so nothing is read from one here. The caller builds the map - statifier_blocks, or any host with a datamodel document to read - which is why this package takes no dependency on one.

When simple_available?/0 is false - the resolved predicator has no Predicator.Simple, or the module the :predicator_simple key points at does not export all four of from_source/1, to_source/1, operators/1 and value_kind/1 - 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"]}

iex> {:ok, [row], nil} =
...>   StatifierUI.Expression.simple("plan == 'pro'", path_types: %{"plan" => :boolean})
iex> {row.control_kind, Enum.map(row.operators, & &1.lexeme)}
{:boolean, ["==", "===", "!=", "!==", "CONTAINS"]}

iex> {:ok, [row], nil} =
...>   StatifierUI.Expression.simple("amount >= 500", path_types: %{"amount" => :string})
iex> Enum.map(row.advisories, & &1.reason)
[:value_kind]

simple_available?()

@spec simple_available?() :: boolean()

Whether the resolved predicator exposes a usable 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".

Usable is four exports, not one loaded module: from_source/1, to_source/1, operators/1 and value_kind/1 all have to be there. Under the ~> 9.4 requirement that is inert, since every admitted predicator carries all four; it is a host overriding :predicator_simple that the condition measures, and a stub exporting three of the four reads as unavailable - which is why the fourth joined the guard when this module started asking upstream what kind a value is. Every function this guard gates then degrades together: simple/2 to :outside, operators/1 to [], and source/2, value_source/2 and segments/1 to :error. A picklist is lost silently, so a host stubbing this module is stubbing the whole surface.

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