Current section
Files
Jump to
Current section
Files
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` using `{:assoc, opts}` tuples:
allowed_fields: [
name: :string,
author: {:assoc,
table: "users",
owner_key: :author_id,
related_key: :id,
fields: [name: :string, email: :string]}
]
Association options:
- `:table` — target table name (string, required)
- `:owner_key` — FK on the source table (atom, required)
- `:related_key` — PK on the target table (atom, required)
- `:fields` — keyword list of available fields, supports nesting (optional,
defaults to `[]` meaning all fields allowed)
When a schema IS available, it takes priority (fully backward compatible).
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
with {:ok, ast} <- parse(query_string),
{:ok, dynamic_expr, joins} <- EctoQueryParser.Builder.build(ast, builder_opts) do
query =
queryable
|> apply_joins(joins)
|> where(^dynamic_expr)
{:ok, query}
end
end
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
defp apply_joins(queryable, joins) do
joins
|> deduplicate_joins()
|> Enum.reduce(queryable, fn join_spec, query ->
if has_named_binding?(query, join_spec.binding) do
query
else
apply_single_join(query, join_spec)
end
end)
end
defp deduplicate_joins(joins) do
joins
|> Enum.uniq_by(& &1.binding)
end
# Schemaless joins: pattern match on :table key
defp apply_single_join(query, %{
binding: binding,
table: table,
owner_key: ok,
related_key: rk,
parent: :root
}) do
from(row in query,
left_join: related in ^table,
on: field(related, ^rk) == field(row, ^ok),
as: ^binding
)
end
defp apply_single_join(query, %{
binding: binding,
table: table,
owner_key: ok,
related_key: rk,
parent: parent
}) do
from([{^parent, p}] in query,
left_join: related in ^table,
on: field(related, ^rk) == field(p, ^ok),
as: ^binding
)
end
# Schema-based joins: pattern match on :assoc key
defp apply_single_join(query, %{binding: binding, assoc: assoc, parent: :root}) do
from(row in query,
left_join: related in assoc(row, ^assoc),
as: ^binding
)
end
defp apply_single_join(query, %{binding: binding, assoc: assoc, parent: parent}) do
from([{^parent, p}] in query,
left_join: related in assoc(p, ^assoc),
as: ^binding
)
end
end