A search request passed to Flicker.Provider.search/2, and the result
of parsing faceted-search input (Spec 003).
Fields
:text— the raw free-text portion, as typed (facet tokens stripped out, quotes on whole-token free text unwrapped). Providers decide how to match it (the built-inFlicker.Providers.AshResourcerunsilike-style matching over its configured search fields); an empty string is a valid query (the picker's open-with-no-input state — providers typically return a default listing rather than an empty result set).:facets— active facet filters, as{key, operator, value}tuples, in the order they appeared in the input. The same key can appear more than once (parse/2doesn't deduplicate) — repeated instances of the same facet are meant to OR together (seeto_filter/1).:input— the verbatim stringparse/2was called with, untouched (facet tokens still in place, quoting/casing/whitespace exactly as typed).:textand:facetsare derived from it and lossy in both directions (facet tokens are stripped from:text;:facets' cast values can't reproduce the original literal, e.g.true/TRUE,7dvs. its resolved date);:inputis the one field a round-trip (e.g. Spec 009 Level 2's URL-state serialisation) can re-parse/2and get back the exact same query — the source of truth to persist, never:textor a reconstruction of:facets.
Summary
Functions
Parses input into a Flicker.Query, given the facets it should
recognise.
Builds an Ash filter_input-shaped map from query.facets: distinct
facet keys AND together, repeated instances of the same facet key OR
together. Returns %{} (a no-op filter) when there are no facets.
Whether every recognised facet token in this query cast cleanly.
Types
@type facet_match() :: {atom(), Flicker.Facet.operator(), term()}
A parsed facet filter: {key, operator, cast value}.
@type t() :: %Flicker.Query{ facets: [facet_match()], input: String.t(), invalid: [Flicker.Query.Invalid.t()], text: String.t() }
A search request / parsed query.
Functions
@spec parse(String.t(), [Flicker.Facet.t()], keyword()) :: t()
Parses input into a Flicker.Query, given the facets it should
recognise.
This is a pure function: the same (input, facets) pair always produces
the same result, and it never raises — arbitrary typing (unterminated
quotes, unknown keys, malformed operators, stray backslashes, mid-token
unicode) always degrades to free text rather than erroring. facets is
a list of Flicker.Facet.t(); keys not present in it are never
recognised as facets.
Grammar
Input is tokenised on whitespace, except whitespace inside a
double-quoted span (worker:"Casey Nguyen" is one token). Inside quotes,
\" is a literal quote and \\ is a literal backslash; any other
backslash is kept as-is. An unterminated quote absorbs the rest of the
input rather than erroring — the whole thing degrades to free text.
A token matches the facet grammar when it looks like key<op>value,
where key is one or more Unicode letters/digits/underscores (a trailing
?, as in active?, is allowed too), op is
one of : >= <= != > <, and value is either a quoted span or
a run of non-whitespace characters. key:value is a match only when:
keynames a facet infacets,opis:(mapped to that facet's:default_op) or an operator in that facet's:operatorslist,valuecasts cleanly to that facet's:type(seeFlicker.Facet).
Any failure at any of those steps — unknown key, disallowed operator,
uncastable value, malformed quoting — degrades the entire token to
free text (appended to :text as typed, with a whole-token surrounding
quote pair stripped and unescaped).
Examples
iex> facets = [
...> %Flicker.Facet{key: :status, type: :enum, values: [:active, :inactive]},
...> %Flicker.Facet{key: :worker}
...> ]
...>
...> Flicker.Query.parse(~s(status:active worker:"Casey Nguyen" visit notes), facets)
%Flicker.Query{
text: "visit notes",
facets: [{:status, :eq, :active}, {:worker, :eq, "Casey Nguyen"}],
input: ~s(status:active worker:"Casey Nguyen" visit notes)
}
iex> Flicker.Query.parse("unknown:value free text", [])
%Flicker.Query{text: "unknown:value free text", facets: [], input: "unknown:value free text"}
@spec to_filter(t(), [Flicker.Facet.t()]) :: map()
Builds an Ash filter_input-shaped map from query.facets: distinct
facet keys AND together, repeated instances of the same facet key OR
together. Returns %{} (a no-op filter) when there are no facets.
This only compiles when ash is present — the boundary
ADR-006
draws around Ash-specific capabilities. It shapes plain data (no
resource, no live query, no Ash.* call) for
Ash.Query.filter_input/2 to consume — e.g.
Ash.Query.filter_input(query, Flicker.Query.to_filter(parsed)).
facets (a list of Flicker.Facet.t(), typically the facet registry
passed to parse/2) resolves each matched key to its
Flicker.Facet.target/1 — a relationship path, aggregate, or
expression-calc name builds a nested filter map instead of a flat
attribute clause. A key with no matching facet (or the default [])
falls back to using the key as the attribute name directly.
Examples
iex> query = %Flicker.Query{text: "", facets: [{:status, :eq, :active}]}
...> Flicker.Query.to_filter(query)
%{"status" => %{"eq" => :active}}
iex> query = %Flicker.Query{
...> text: "",
...> facets: [{:city, :eq, "Melbourne"}, {:city, :eq, "Sydney"}]
...> }
...>
...> Flicker.Query.to_filter(query)
%{"or" => [%{"city" => %{"eq" => "Melbourne"}}, %{"city" => %{"eq" => "Sydney"}}]}
iex> query = %Flicker.Query{text: "", facets: [{:worker, :eq, "id-123"}]}
...> facets = [%Flicker.Facet{key: :worker, target: [:worker, :full_name]}]
...> Flicker.Query.to_filter(query, facets)
%{"worker" => %{"full_name" => %{"eq" => "id-123"}}}
Whether every recognised facet token in this query cast cleanly.
A convenience for callers who want the all-or-nothing reading. It is deliberately not how components gate dispatch: validation is per-facet, so one broken token contributes no filter clause while every other facet and the free text still run (ADR-012).
Examples
iex> Flicker.Query.valid?(Flicker.Query.parse("anything", []))
true
iex> facets = [Flicker.Facet.new(key: :price, type: :integer)]
...> Flicker.Query.parse("price:abc", facets) |> Flicker.Query.valid?()
false