Flicker.Query (Flicker v0.1.0)

Copy Markdown View Source

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-in Flicker.Providers.AshResource runs ilike-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/2 doesn't deduplicate) — repeated instances of the same facet are meant to OR together (see to_filter/1).
  • :input — the verbatim string parse/2 was called with, untouched (facet tokens still in place, quoting/casing/whitespace exactly as typed). :text and :facets are 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, 7d vs. its resolved date); :input is the one field a round-trip (e.g. Spec 009 Level 2's URL-state serialisation) can re-parse/2 and get back the exact same query — the source of truth to persist, never :text or a reconstruction of :facets.

Summary

Types

A parsed facet filter: {key, operator, cast value}.

t()

A search request / parsed query.

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

facet_match()

@type facet_match() :: {atom(), Flicker.Facet.operator(), term()}

A parsed facet filter: {key, operator, cast value}.

t()

@type t() :: %Flicker.Query{
  facets: [facet_match()],
  input: String.t(),
  invalid: [Flicker.Query.Invalid.t()],
  text: String.t()
}

A search request / parsed query.

Functions

parse(input, facets \\ [], opts \\ [])

@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:

  • key names a facet in facets,
  • op is : (mapped to that facet's :default_op) or an operator in that facet's :operators list,
  • value casts cleanly to that facet's :type (see Flicker.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"}

to_filter(query, facet_defs \\ [])

@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"}}}

valid?(query)

@spec valid?(t()) :: boolean()

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