Bier.QueryParser (bier v0.1.0)

Copy Markdown View Source

Parser for the PostgREST-style request query string.

parse_request/1 turns a raw query string into a structured query plan (select tree, filters, order, pagination, write params) that Bier.QueryExecutor renders into one parameterized SQL statement.

The leaf grammars (json paths, filter expressions, order terms, embed and aggregate heads, identifiers) are nimble_parsec combinators compiled to binary-matching clauses (1.6x-5.9x faster than the regex/String.split parsing they replaced, proven behavior-identical against the conformance suite -- see bench/REPORT.md). The recursive/orchestration layer (the select tree, logic groups, embeds, and split_top_commas/1) deliberately stays on the string path, where nimble_parsec offers no benefit: split_top_commas/1 is a depth-tracking, quote-aware splitter that must tolerate arbitrary inner text such as {1,"a,b"}, and the genuinely recursive grammars recurse back through it and the leaf parsers.

Generated file

The committed lib/bier/query_parser.ex is generated from this template (lib/bier/query_parser.ex.exs) via mix gen.parsers, which runs mix nimble_parsec.compile. Only the combinators between the parsec marker comments are expanded; everything else passes through verbatim. The generated .ex has no runtime dependency on nimble_parsec (a dev-only dependency). Edit this template and re-run mix gen.parsers; never edit the .ex directly.

Summary

Functions

True when a select field is an aggregate: count(), col.sum(), alias:col.sum()::cast, alias:col->>key::int.sum()::cast. A bare name() is an aggregate only when name is a known aggregate function; otherwise it is an empty-projection embed. An <operand>.fn() form is always an aggregate.

True when a select field references an embedding: it has a top-level ( not preceded by a . aggregate marker, i.e. name(...) / alias:name(...) / name!hint(...).

Match a logic-group prefix: and(/or(/not.and(/not.or(.

Split an embed term into its name!hint... head and inner sub-select.

Parse a column filter — col_raw (which may carry a json path) plus the [not.]op[.modifier].value tail — into a filter node.

Parse a json-path column reference (col, col->a->>b, col->0, col->>-3).

Parse one order term: a column order <col>[->json][.asc|.desc][.nullsfirst|.nullslast] or a related order <rel>(<col>[->json])[.mods] (ordering by a to-one embedded resource).

Parse a full request query string into a structured query plan.

Parse a scalar select field [alias:]col[::cast][->json] into a :field node.

Peel a trailing )::cast off an aggregate term (the regex ^(.*\))::([A-Za-z0-9_ ]+)$).

Split the trailing aggregate call off a select field.

Split a .-delimited query-string key into its segments, treating a quoted field name as atomic ("a.dotted.column" is one segment, tasks.name is two).

Split a filter's op[.modifier].value tail.

True when col is a valid PostgREST unquoted identifier ([A-Za-z_][A-Za-z0-9_ -]*).

Functions

aggregate?(field)

@spec aggregate?(String.t()) :: boolean()

True when a select field is an aggregate: count(), col.sum(), alias:col.sum()::cast, alias:col->>key::int.sum()::cast. A bare name() is an aggregate only when name is a known aggregate function; otherwise it is an empty-projection embed. An <operand>.fn() form is always an aggregate.

embed?(field)

@spec embed?(String.t()) :: boolean()

True when a select field references an embedding: it has a top-level ( not preceded by a . aggregate marker, i.e. name(...) / alias:name(...) / name!hint(...).

logic_prefix(member)

@spec logic_prefix(String.t()) :: {boolean(), :and | :or, String.t()} | nil

Match a logic-group prefix: and(/or(/not.and(/not.or(.

Returns {negate?, :and | :or, "(...)"} when member begins with and(/or(/not.and(/not.or( (whitespace allowed before the () and ends in ); otherwise nil. The returned group keeps its surrounding parens.

parse_embed_parts(field)

@spec parse_embed_parts(String.t()) :: {:ok, String.t(), String.t()} | :error

Split an embed term into its name!hint... head and inner sub-select.

Returns {:ok, head_string, inner_string} where head is the name!hint... text and inner is everything between the first ( and the final ) (kept opaque). Returns :error when the string is not a well-formed embed term.

parse_filter_expr(col_raw, opval)

@spec parse_filter_expr(String.t(), String.t()) :: {:ok, map()} | :error

Parse a column filter — col_raw (which may carry a json path) plus the [not.]op[.modifier].value tail — into a filter node.

Returns {:ok, %{column:, json_path:, op:, modifier:, negate:, value:}} or :error.

parse_json_path(str)

@spec parse_json_path(String.t()) ::
  {:ok, {String.t(), [{:arrow | :arrow_text, String.t()}]}} | :error

Parse a json-path column reference (col, col->a->>b, col->0, col->>-3).

Returns {:ok, {base_col, [{:arrow | :arrow_text, key}]}} or :error.

parse_order_term(term)

@spec parse_order_term(String.t()) :: {:ok, map()} | {:error, term()}

Parse one order term: a column order <col>[->json][.asc|.desc][.nullsfirst|.nullslast] or a related order <rel>(<col>[->json])[.mods] (ordering by a to-one embedded resource).

Returns {:ok, term} (column or related order map) or {:error, {:order_parse, ...}}.

parse_request(query_string)

@spec parse_request(String.t()) :: {:ok, map()} | {:error, term()}

Parse a full request query string into a structured query plan.

Returns {:ok, plan} where plan is a map with keys :select, :filters, :order, :limit, :offset, or {:error, reason}.

select/order items and column filters are returned as data; the executor resolves them against the relation's columns and renders SQL.

parse_scalar_select(field)

@spec parse_scalar_select(String.t()) ::
  {:ok, map()} | {:error, {:select_parse, String.t()}}

Parse a scalar select field [alias:]col[::cast][->json] into a :field node.

Returns {:ok, %{kind: :field, ...}} or {:error, {:select_parse, field}}.

parse_select_tree(sel)

peel_agg_cast(rest)

@spec peel_agg_cast(String.t()) :: {String.t() | nil, String.t()}

Peel a trailing )::cast off an aggregate term (the regex ^(.*\))::([A-Za-z0-9_ ]+)$).

Returns {cast, rest} — the trimmed cast and the rest (ending in )) — or {nil, original} when there is no trailing )::cast.

split_agg_call(str)

@spec split_agg_call(String.t()) :: {:ok, String.t(), String.t()} | :error

Split the trailing aggregate call off a select field.

Returns {:ok, operand, fun} where operand is everything before the . that introduces the call — "" for the field-less count() form — and fun is the aggregate name. :error when the string does not end in an empty call <fun>().

The operand is kept opaque here because PostgREST parses it as a full field reference (pField plus an optional ::cast), so it may carry a json path and a cast: jsonb_col->>key::integer.sum().

split_key(key)

@spec split_key(String.t()) :: [String.t()]

Split a .-delimited query-string key into its segments, treating a quoted field name as atomic ("a.dotted.column" is one segment, tasks.name is two).

split_op_value(opval)

@spec split_op_value(String.t()) ::
  {:ok, String.t(), String.t() | nil, String.t()} | :error

Split a filter's op[.modifier].value tail.

Returns {:ok, op, modifier, value} or :error. modifier is nil unless a (any)/(all)/(lang) quantifier is present.

valid_identifier?(col)

@spec valid_identifier?(String.t()) :: boolean()

True when col is a valid PostgREST unquoted identifier ([A-Za-z_][A-Za-z0-9_ -]*).