Packages

A query language parser that converts human-readable filter strings into Ecto WHERE clauses.

Current section

Files

Jump to
ecto_query_parser lib ecto_query_parser.ex
Raw

lib/ecto_query_parser.ex

defmodule EctoQueryParser do
@moduledoc """
A query language parser for Ecto.
Parses string input into AST nodes that can be used for building Ecto queries.
"""
import Ecto.Query
defdelegate parse(input), to: EctoQueryParser.Parser
@doc """
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`.
## Options
* `: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/2` for proper casting. Without type info, raw
`json_extract_path` is used.
Dotted paths use atom notation (e.g., `:"author.name"`).
* **Schemaless queries** — when using a string table name (e.g., `from("posts")`),
associations can be defined directly in `allowed_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_to` and `has_many` additionally 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_many` additionally 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_one` use
`LEFT JOIN`, `has_many`/`many_to_many` use `EXISTS`. 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}`.
"""
def apply(queryable, query_string, opts \\ []) do
schema = extract_schema(queryable)
builder_opts = if schema, do: Keyword.put(opts, :schema, schema), else: opts
query = ensure_source_binding(Ecto.Queryable.to_query(queryable))
source_binding = query.from.as
builder_opts = Keyword.put(builder_opts, :source_binding, source_binding)
with {:ok, ast} <- parse(query_string),
{:ok, rewritten} <- EctoQueryParser.ExistsRewriter.rewrite(ast, builder_opts),
{:ok, dynamic_expr, joins} <- EctoQueryParser.Builder.build(rewritten, builder_opts) do
query =
query
|> EctoQueryParser.Joins.apply(joins)
|> where(^dynamic_expr)
{:ok, query}
end
end
# Names the source binding so EXISTS subqueries can reference it via
# `parent_as(^name)`. If the user already named the source, keep theirs;
# otherwise patch in `:__eqp_source`. Updates both `from.as` and the
# `aliases` registry — Ecto looks up named bindings in the latter.
defp ensure_source_binding(%Ecto.Query{from: %{as: nil} = from, aliases: aliases} = query) do
%{query | from: %{from | as: :__eqp_source}, aliases: Map.put(aliases, :__eqp_source, 0)}
end
defp ensure_source_binding(%Ecto.Query{} = query), do: query
defp extract_schema(module) when is_atom(module) do
(Code.ensure_loaded?(module) and function_exported?(module, :__schema__, 1) and module) || nil
end
defp extract_schema(%Ecto.Query{from: %{source: {_, schema}}})
when is_atom(schema) and not is_nil(schema) do
schema
end
defp extract_schema(_), do: nil
end