A query language parser for Ecto.
Parses string input into AST nodes that can be used for building Ecto queries.
Summary
Functions
Parses a query string and applies it as a WHERE clause to the given queryable.
Parses and compiles a pipe query into an Ecto query.
Lists the {{name}} parameters referenced by a filter string or a pipe
query string, in order of first appearance.
Parses a pipe query string into an EctoQueryParser.Pipe.Query struct.
Functions
Parses a query string and applies it as a WHERE clause to the given queryable.
Supports dotted identifiers (e.g., author.name) that automatically resolve
to left joins on schema associations, or to JSONB path extraction when the
first segment refers to a :map field on the schema.
JSONB column support
When a dotted identifier like metadata.key is used and the schema defines
metadata as a :map field, the builder uses json_extract_path/2 instead
of creating a join. Nested paths like metadata.nested.key are supported.
To enable type casting on JSON values (essential for numeric/boolean comparisons),
use the keyword list format for :allowed_fields.
Parameters and optional groups
{{name}} placeholders may appear anywhere a literal may appear and are
bound at build time via the :params option (params: %{"name" => value}).
Bound values behave exactly like inline literals: they get the same type
coercion against the field's type and are bound as prepared-statement
parameters. An unbound (missing or nil) parameter is a build error —
unless every occurrence sits inside an [[ ... ]] optional group, in which
case the group is pruned from the query:
status == {{status}} [[AND created_at >= {{start}}]]Use parameters/1 to discover the parameters a filter string references.
Options
:params- map binding{{name}}parameters to values (%{"name" => value}; atom keys are also accepted).:literal_transform-fun(ecto_type, raw_string)invoked for string literals (and bound string parameter values) compared against a typed field, before the built-in coercion. Return{:ok, term}to replace the value,{:range, {lo, hi}}to expand the literal into an inclusive range (comparison and BETWEEN operators only), or:defaultto keep the normal behavior.:allowed_fields- controls which fields are permitted. Supports two formats:- Plain list (access control only):
[:name, :age, :"metadata.key"] - Keyword list (access control + type casting):
[name: :string, metadata: :map, "metadata.key": :string, "metadata.age": :integer]
When the keyword format provides a type for a JSON sub-path, the result is wrapped with
type/2for proper casting. Without type info, rawjson_extract_pathis used.Dotted paths use atom notation (e.g.,
:"author.name").- Plain list (access control only):
Schemaless queries — when using a string table name (e.g.,
from("posts")), associations can be defined directly inallowed_fields. Three relationship kinds are recognized;{:assoc, opts}remains a backward-compatible alias for{:belongs_to, opts}.allowed_fields: [ name: :string, # belongs_to (LEFT JOIN) author: {:belongs_to, table: "users", owner_key: :author_id, related_key: :id, fields: [name: :string, email: :string]}, # has_many (EXISTS subquery; no row duplication) comments: {:has_many, table: "comments", owner_key: :id, related_key: :post_id, fields: [body: :string, spam: :boolean]}, # many_to_many (EXISTS through join table) tags: {:many_to_many, table: "tags", join_through: "post_tags", join_owner_key: :post_id, join_related_key: :tag_id, owner_key: :id, related_key: :id, fields: [name: :string]} ]Options shared by all three:
:table— target table name (string, required):fields— nested allowed_fields; supports further nesting (optional):prefix— schema prefix for the target table (optional, multi-tenant)
belongs_toandhas_manyadditionally require::owner_key— FK on the source for belongs_to; PK on the source for has_many:related_key— PK on the target for belongs_to; FK on the target for has_many
many_to_manyadditionally requires::join_through— name of the join table (string):join_owner_key— FK in the join table pointing at the source:join_related_key— FK in the join table pointing at the target:owner_key/:related_key— the columns those FKs point at:join_prefix— optional schema prefix for the join table
When an Ecto schema is available, association cardinality is auto-detected from
__schema__(:association, name)—belongs_to/has_oneuseLEFT JOIN,has_many/many_to_manyuseEXISTS. No annotation needed.
Plural-association semantics
When multiple predicates filter the same plural alias under the same boolean connector, they collapse into one EXISTS subquery:
comments.spam == false AND comments.body contains "ship"— one EXISTS, both predicates AND-ed inside.comments.spam == false OR comments.body contains "ship"— one EXISTS, predicates OR-ed inside.- Predicates on different plural aliases produce separate EXISTS clauses.
v1 restriction: a plural association may only appear as the first segment
of a dotted path. comments.author.name is allowed; author.comments.body
returns an error.
Returns {:ok, query} or {:error, reason}. Parse failures return
{:error, %EctoQueryParser.ParseError{}} (with line/column position
information); builder and validation errors return {:error, binary}.
Parses and compiles a pipe query into an Ecto query.
Accepts a pipe query string or an already-parsed
EctoQueryParser.Pipe.Query struct. Returns {:ok, query, columns} or
{:error, reason}, where reason is an EctoQueryParser.ParseError
(parse failures), an EctoQueryParser.ValidationError (stage-level
validation failures, positioned when the offending token's location is
known), or a plain binary (errors arising inside a filter stage's
boolean expression, prefixed with the stage that produced them).
Output columns
Aggregation and select aliases come from untrusted input, so they are
never turned into atoms. Instead, every projection stage (select /
group) selects into positional atom keys :c0, :c1, … (a fixed,
compile-time set; at most 64 columns per stage) and columns returns the
mapping back to the user-facing names, in output order:
{:ok, query, columns} =
EctoQueryParser.build_pipe(
"orders | group region { total = sum(amount) } | sort -total")
columns
#=> [%{name: "region", key: :c0}, %{name: "total", key: :c1}]
Repo.all(query)
#=> [%{c0: "north", c1: 1200}, ...]Rows come back keyed by the positional atoms; rename them with columns.
When the pipe has no projection stage (bare source, or filter/sort/limit
only), the query keeps the source's own row shape and columns is nil —
note that a schemaless table source then has no select clause at all, so
attach one (query |> select(...)) before executing.
Sources
A table source compiles to a schemaless from (optionally
schema-qualified: sales.orders sets the query prefix). The table name is
not validated — parse first with parse_pipe/1 and check the struct's
:source against your catalog, then pass the matching :allowed_fields.
An @slug source is resolved through the required :resolve_source
option:
resolve_source: fn slug ->
case MyApp.Questions.fetch(slug) do
{:ok, question} -> {:ok, question.query, question.fields}
:error -> {:error, "no such question"}
end
endThe resolver receives the slug (without the @) and must return
{:ok, queryable, fields} — the queryable is inlined as a subquery
source, and fields is its output field spec in the same format as
:allowed_fields (so stages can be validated and typed against it) — or
{:error, message}. The queryable must be usable as an Ecto subquery
(i.e. carry a select, as any schema-based or previously-built query
does). A @slug in the text without a :resolve_source option is a
build error.
Options
:allowed_fields— field spec for a table source, exactly as inapply/3(allowlisting, types for coercion, association tuples for schemaless joins/EXISTS).:params—{{name}}parameter bindings for filter stages.:literal_transform— the same hook as inapply/3; applies inside filter stages, including filters over grouped output (where breakout columns keep their underlying type and aggregation aliases get the aggregate's type).:resolve_source— resolver for@slugsources (see above).
Row capping remains the caller's job: limit/offset stages are applied
as written, and a caller-enforced hard cap should be applied to the
returned query as today.
Lists the {{name}} parameters referenced by a filter string or a pipe
query string, in order of first appearance.
Each entry is %{name: String.t(), required: boolean()}. A parameter is
optional (required: false) iff every occurrence of its name sits
inside an [[ ... ]] optional group.
iex> EctoQueryParser.parameters("status == {{status}} [[AND created_at >= {{start}}]]")
{:ok, [%{name: "status", required: true}, %{name: "start", required: false}]}Pipe queries are supported — parameters may appear in any filter stage:
iex> EctoQueryParser.parameters("orders | filter status == {{status}} | limit 5")
{:ok, [%{name: "status", required: true}]}Also accepts an already-parsed EctoQueryParser.Pipe.Query.
Returns {:error, %EctoQueryParser.ParseError{}} if the string parses
neither as a filter nor as a pipe query (whichever parse consumed more
input provides the error).
Parses a pipe query string into an EctoQueryParser.Pipe.Query struct.
A pipe query is a source (a table name like orders / sales.orders, or
an external @slug reference) followed by zero or more |-separated
stages:
orders
| filter status == "paid" AND amount >= {{min_amount}}
| group customer.region { total = sum(amount), n = count() }
| sort -total
| limit 10Whitespace (including newlines) is insignificant around |; a bare source
with no stages is a valid query. The filter stage embeds the plain
filter grammar verbatim, parameters and [[optional]] groups included.
Parsing performs no validation against a schema or field spec — use
build_pipe/2 for that. The returned struct exposes the query's :source
so callers can validate the referenced table before building.
Returns {:ok, %EctoQueryParser.Pipe.Query{}} or
{:error, %EctoQueryParser.ParseError{}}.