Current section

Files

Jump to
query_builder lib query_builder.ex
Raw

lib/query_builder.ex

defmodule QueryBuilder do
require Ecto.Query
alias Ecto.Query
defmacro __using__(opts) do
quote do
require QueryBuilder.Schema
QueryBuilder.Schema.__using__(unquote(opts))
end
end
def new(ecto_query) do
%QueryBuilder.Query{ecto_query: ensure_query_has_binding(ecto_query)}
end
@doc ~S"""
Builds an `Ecto.SubQuery` using QueryBuilder operations.
This is a convenience wrapper around `from_opts/3` (`mode: :full`) + `Ecto.Query.subquery/1`.
Example:
```elixir
user_ids =
QueryBuilder.subquery(User,
where: [deleted: false],
select: :id
)
Article
|> QueryBuilder.where({:author_id, :in, user_ids})
|> Repo.all()
```
"""
def subquery(queryable, opts \\ []) do
queryable
|> from_opts(opts, mode: :full)
|> Ecto.Queryable.to_query()
|> Ecto.Query.subquery()
end
# ----------------------------------------------------------------------------
# Pagination
# ----------------------------------------------------------------------------
def paginate(%QueryBuilder.Query{} = query, repo, opts \\ []) do
paginate_cursor(query, repo, opts)
end
def paginate_cursor(%QueryBuilder.Query{} = query, repo, opts \\ []) do
QueryBuilder.Pagination.paginate_cursor(query, repo, opts)
end
def paginate_offset(%QueryBuilder.Query{} = query, repo, opts \\ []) do
QueryBuilder.Pagination.paginate_offset(query, repo, opts)
end
def default_page_size() do
Application.get_env(:query_builder, :default_page_size, 100)
end
# ----------------------------------------------------------------------------
# Preloads
# ----------------------------------------------------------------------------
@doc ~S"""
Preloads associations using *separate* queries (Ecto's default preload behavior).
This always performs query-preload, even if the association is joined in SQL.
Example:
```
QueryBuilder.preload_separate(query, [role: :permissions, articles: [:stars, comments: :user]])
```
"""
def preload_separate(%QueryBuilder.Query{} = query, assoc_fields) do
QueryBuilder.PreloadOps.preload_separate(query, assoc_fields)
end
def preload_separate(ecto_query, assoc_fields) do
ecto_query = ensure_query_has_binding(ecto_query)
preload_separate(%QueryBuilder.Query{ecto_query: ecto_query}, assoc_fields)
end
@doc ~S"""
Preloads a (direct) association using a separate query with an explicit scope.
This is the QueryBuilder equivalent of Ecto’s query-based separate preload:
```elixir
User
|> preload([u],
authored_articles:
^from(a in assoc(u, :authored_articles),
where: a.published == true,
order_by: [desc: a.inserted_at]
)
)
```
Supported options:
- `where:` filters (QueryBuilder `where/2` filter shape)
- `order_by:` keyword list (QueryBuilder `order_by/2` shape)
Restrictions (fail-fast):
- Only supports a single, direct association (no nested paths).
- Filters/order_by must reference fields on the association schema (no `@assoc` tokens).
- Does not accept custom filter/order_by functions. Use an Ecto preload query for advanced cases.
- Cannot be combined with nested preloads under the same association; use an explicit Ecto query-based preload query instead.
"""
def preload_separate_scoped(query, assoc_field, opts \\ [])
def preload_separate_scoped(_query, nil, _opts) do
raise ArgumentError, "preload_separate_scoped/3 expects an association field, got nil"
end
def preload_separate_scoped(_query, _assoc_field, nil) do
raise ArgumentError, "preload_separate_scoped/3 expects opts to be a keyword list, got nil"
end
def preload_separate_scoped(%QueryBuilder.Query{} = query, assoc_field, opts)
when is_atom(assoc_field) do
QueryBuilder.PreloadOps.preload_separate_scoped(query, assoc_field, opts)
end
def preload_separate_scoped(query, assoc_field, opts) when is_atom(assoc_field) do
ecto_query = ensure_query_has_binding(query)
preload_separate_scoped(%QueryBuilder.Query{ecto_query: ecto_query}, assoc_field, opts)
end
def preload_separate_scoped(_query, assoc_field, _opts) do
raise ArgumentError,
"preload_separate_scoped/3 expects `assoc_field` to be an atom (direct association), got: #{inspect(assoc_field)}"
end
@doc ~S"""
Preloads associations *through join bindings* (join-preload).
This requires the association to already be joined (for example because you filtered
through it, ordered by it, or explicitly joined it with `left_join/2`). If the
association isn't joined, this raises `ArgumentError`.
Example:
```
User
|> QueryBuilder.left_join(:role)
|> QueryBuilder.preload_through_join(:role)
```
"""
def preload_through_join(%QueryBuilder.Query{} = query, assoc_fields) do
QueryBuilder.PreloadOps.preload_through_join(query, assoc_fields)
end
def preload_through_join(ecto_query, assoc_fields) do
ecto_query = ensure_query_has_binding(ecto_query)
preload_through_join(%QueryBuilder.Query{ecto_query: ecto_query}, assoc_fields)
end
# ----------------------------------------------------------------------------
# Filtering
# ----------------------------------------------------------------------------
@doc ~S"""
An AND where query expression.
Example:
```
QueryBuilder.where(query, firstname: "John")
```
"""
def where(_query, nil) do
raise ArgumentError,
"where/2 expects `filters` to be a keyword list (or a list of filters); got nil"
end
def where(query, filters) do
where(query, [], filters)
end
@doc ~S"""
An AND where query expression.
Associations are passed in second argument; fields from these associations can then
be referenced by writing the field name, followed by the "@" character and the
association name, as an atom. For example: `:name@users`.
Example:
```
QueryBuilder.where(query, [role: :permissions], name@permissions: :write)
```
OR clauses may be passed through last argument `opts`. For example:
```elixir
QueryBuilder.where(query, [], [firstname: "John"], or: [firstname: "Alice", lastname: "Doe"], or: [firstname: "Bob"])
```
"""
def where(query, assoc_fields, filters, or_filters \\ [])
def where(_query, _assoc_fields, nil, _or_filters) do
raise ArgumentError,
"where/4 expects `filters` to be a keyword list (or a list of filters); got nil"
end
def where(_query, _assoc_fields, _filters, nil) do
raise ArgumentError,
"where/4 expects `or_filters` to be a keyword list like `[or: [...], or: [...]]`; got nil"
end
def where(%QueryBuilder.Query{} = query, _assoc_fields, [], []) do
query
end
def where(%QueryBuilder.Query{} = query, assoc_fields, filters, or_filters) do
%{
query
| operations: [
{:where, assoc_fields, [filters, or_filters]} | query.operations
]
}
end
def where(ecto_query, assoc_fields, filters, or_filters) do
ecto_query = ensure_query_has_binding(ecto_query)
where(%QueryBuilder.Query{ecto_query: ecto_query}, assoc_fields, filters, or_filters)
end
@doc ~S"""
An OR where query expression (an OR of AND groups).
Examples:
```elixir
QueryBuilder.where_any(query, [[firstname: "John"], [firstname: "Alice", lastname: "Doe"]])
```
```elixir
QueryBuilder.where_any(query, :role, [[name@role: "admin"], [name@role: "author"]])
```
"""
def where_any(query, or_groups) do
where_any(query, [], or_groups)
end
def where_any(query, assoc_fields, or_groups)
def where_any(%QueryBuilder.Query{} = query, assoc_fields, or_groups) do
or_groups =
QueryBuilder.Filters.normalize_or_groups!(
or_groups,
:where_any,
"where_any/2 and where_any/3"
)
case Enum.reject(or_groups, &(&1 == [])) do
[] ->
query
[first | rest] ->
or_filters = Enum.map(rest, &{:or, &1})
where(query, assoc_fields, first, or_filters)
end
end
def where_any(ecto_query, assoc_fields, or_groups) do
ecto_query = ensure_query_has_binding(ecto_query)
where_any(%QueryBuilder.Query{ecto_query: ecto_query}, assoc_fields, or_groups)
end
# ----------------------------------------------------------------------------
# Selecting
# ----------------------------------------------------------------------------
@doc ~S"""
A select query expression.
Selection supports:
- a single field token (`:name` or `:name@role`) → selects a single value
- a tuple of tokens/values → selects a tuple
- a list of field tokens → selects a map keyed by the tokens
- a map of output keys to field tokens → selects a map with your keys
- a custom 1-arity function escape hatch (receives a token resolver)
Examples:
```elixir
User |> QueryBuilder.select(:name) |> Repo.all()
```
```elixir
User |> QueryBuilder.select([:id, :name]) |> Repo.all()
# => [%{id: 100, name: "Alice"}, ...]
```
```elixir
User |> QueryBuilder.select(:role, %{role_name: :name@role}) |> Repo.all()
```
```elixir
User |> QueryBuilder.select({:id, :name}) |> Repo.all()
# => [{100, "Alice"}, ...]
```
Like Ecto, only one `select` expression is allowed. Calling `select/*` more
than once (or calling `select/*` after `select_merge/*`) raises. Use
`select_merge/*` to accumulate fields into the selection.
Note: `paginate_cursor/3` / `paginate_offset/3` require selecting the root struct; using `select/*` will make
pagination raise (fail-fast).
"""
def select(query, selection) do
select(query, [], selection)
end
def select(query, assoc_fields, selection)
def select(%QueryBuilder.Query{} = query, assoc_fields, selection) do
if Enum.any?(query.operations, fn
{type, _assocs, _args}
when type in [:select, :select_merge, :left_join_latest, :left_join_top_n] ->
true
_ ->
false
end) do
raise ArgumentError,
"only one select expression is allowed in query; " <>
"call `select/*` at most once and use `select_merge/*` to add fields"
end
%{
query
| operations: [{:select, assoc_fields, [selection]} | query.operations]
}
end
def select(ecto_query, assoc_fields, selection) do
ecto_query = ensure_query_has_binding(ecto_query)
select(%QueryBuilder.Query{ecto_query: ecto_query}, assoc_fields, selection)
end
@doc ~S"""
A `select_merge` query expression.
This merges a map into the existing selection (Ecto `select_merge` semantics).
Notes:
- If there is no prior `select`, Ecto merges into the root struct by default.
- `select_merge` requires explicit keys for `field@assoc` values (use a map).
- `paginate_cursor/3` / `paginate_offset/3` require selecting the root struct; any custom select expression
(including `select_merge`) will make pagination raise (fail-fast).
Examples:
```elixir
User
|> QueryBuilder.select_merge(%{name: :name})
|> Repo.all()
```
```elixir
User
|> QueryBuilder.select_merge(:role, %{role_name: :name@role})
|> Repo.all()
```
"""
def select_merge(query, selection) do
select_merge(query, [], selection)
end
def select_merge(query, assoc_fields, selection)
def select_merge(%QueryBuilder.Query{} = query, assoc_fields, selection) do
if Enum.any?(query.operations, fn
{type, _assocs, _args} when type in [:left_join_latest, :left_join_top_n] -> true
_ -> false
end) do
raise ArgumentError,
"select_merge/* cannot be combined with left_join_latest/3 or left_join_top_n/3; " <>
"these functions set a custom select (`{root, assoc}`), so select_merge is not supported"
end
%{
query
| operations: [
{:select_merge, assoc_fields, [selection]} | query.operations
]
}
end
def select_merge(ecto_query, assoc_fields, selection) do
ecto_query = ensure_query_has_binding(ecto_query)
select_merge(%QueryBuilder.Query{ecto_query: ecto_query}, assoc_fields, selection)
end
# ----------------------------------------------------------------------------
# Aggregates
# ----------------------------------------------------------------------------
@doc ~S"""
Aggregate helpers for grouped queries.
These return aggregate expressions that can be used in `select/*`, `order_by/*`, and `having/*`.
Examples:
```
QueryBuilder.count(:id)
QueryBuilder.count(:id, :distinct)
QueryBuilder.sum(:amount)
```
"""
def count(), do: %QueryBuilder.Aggregate{op: :count, arg: nil, modifier: nil}
def count(token) when is_atom(token) or is_binary(token),
do: %QueryBuilder.Aggregate{op: :count, arg: token, modifier: nil}
def count(token, :distinct) when is_atom(token) or is_binary(token),
do: %QueryBuilder.Aggregate{op: :count, arg: token, modifier: :distinct}
def count_distinct(token), do: count(token, :distinct)
def avg(token) when is_atom(token) or is_binary(token),
do: %QueryBuilder.Aggregate{op: :avg, arg: token, modifier: nil}
def sum(token) when is_atom(token) or is_binary(token),
do: %QueryBuilder.Aggregate{op: :sum, arg: token, modifier: nil}
def min(token) when is_atom(token) or is_binary(token),
do: %QueryBuilder.Aggregate{op: :min, arg: token, modifier: nil}
def max(token) when is_atom(token) or is_binary(token),
do: %QueryBuilder.Aggregate{op: :max, arg: token, modifier: nil}
@doc ~S"""
Aggregate helper: `array_agg` (Postgres-only).
This returns an aggregate expression that can be used in `select/*`, `order_by/*`,
and `having/*` (typically with `group_by/*`).
Options:
- `:distinct?` (optional) - boolean (default: `false`)
- `:order_by` (optional) - a keyword list like `order_by/*` (default: `[]`).
When `distinct?: true`, `order_by` is restricted to ordering by the aggregated token
itself (Postgres restriction for `DISTINCT` aggregates).
Supports up to 5 order terms.
- `:filter` (optional) - a filter DSL value (like `where/*`), an Ecto dynamic, or a 1-arity
function that returns a dynamic. This is compiled into `FILTER (WHERE ...)` (Postgres).
The DSL form is AND-only; for OR logic, use a dynamic/function.
Examples:
```elixir
Article
|> QueryBuilder.group_by(:author_id)
|> QueryBuilder.select(%{
author_id: :author_id,
publisher_ids: QueryBuilder.array_agg(:publisher_id, distinct?: true, order_by: [asc: :publisher_id])
})
|> Repo.all()
```
"""
def array_agg(token, opts \\ [])
def array_agg(token, opts) when (is_atom(token) or is_binary(token)) and is_list(opts) do
validate_array_agg_opts!(token, opts)
distinct? = Keyword.get(opts, :distinct?, false)
order_by = Keyword.get(opts, :order_by, [])
filter = Keyword.get(opts, :filter, nil)
modifier = if distinct?, do: :distinct, else: nil
%QueryBuilder.Aggregate{
op: :array_agg,
arg: token,
modifier: modifier,
order_by: order_by,
filter: filter
}
end
def array_agg(token, opts) do
raise ArgumentError,
"array_agg/2 expects a token (atom/string) and a keyword list, got: " <>
"#{inspect(token)}, #{inspect(opts)}"
end
defp validate_array_agg_opts!(token, opts) do
unless Keyword.keyword?(opts) do
raise ArgumentError,
"array_agg/2 expects `opts` to be a keyword list, got: #{inspect(opts)}"
end
allowed_keys = [:distinct?, :order_by, :filter]
unknown_keys = opts |> Keyword.keys() |> Enum.uniq() |> Kernel.--(allowed_keys)
if unknown_keys != [] do
raise ArgumentError,
"array_agg/2 got unknown options: #{inspect(unknown_keys)} (supported: #{inspect(allowed_keys)})"
end
distinct? = Keyword.get(opts, :distinct?, false)
unless is_boolean(distinct?) do
raise ArgumentError,
"array_agg/2 expects `:distinct?` to be a boolean, got: #{inspect(distinct?)}"
end
order_by = Keyword.get(opts, :order_by, [])
if is_nil(order_by) do
raise ArgumentError,
"array_agg/2 expects `:order_by` to be a keyword list (or list of order expressions), got nil"
end
if order_by != [] and not (is_list(order_by) and Keyword.keyword?(order_by)) do
raise ArgumentError,
"array_agg/2 expects `:order_by` to be a keyword list (or list of order expressions), got: #{inspect(order_by)}"
end
if length(order_by) > 5 do
raise ArgumentError,
"array_agg/2 supports up to 5 order_by terms, got: #{inspect(length(order_by))}"
end
Enum.each(List.wrap(order_by), fn
{direction, expr} when is_atom(direction) ->
if distinct? and
not ((is_atom(expr) or is_binary(expr)) and to_string(expr) == to_string(token)) do
raise ArgumentError,
"array_agg/2 with `distinct?: true` requires `order_by` expressions to match the aggregated token " <>
"(Postgres restriction for DISTINCT aggregates); got: #{inspect(expr)} (expected: #{inspect(token)})"
end
:ok
other ->
raise ArgumentError,
"array_agg/2 expects `:order_by` entries to look like `{direction, expr}`, got: #{inspect(other)}"
end)
filter = Keyword.get(opts, :filter, nil)
validate_array_agg_filter_opt!(filter)
:ok
end
defp validate_array_agg_filter_opt!(nil), do: :ok
defp validate_array_agg_filter_opt!([]), do: :ok
defp validate_array_agg_filter_opt!(%Ecto.Query.DynamicExpr{}), do: :ok
defp validate_array_agg_filter_opt!(fun) when is_function(fun, 1), do: :ok
defp validate_array_agg_filter_opt!(filters) when is_list(filters) do
if Keyword.keyword?(filters) and Keyword.has_key?(filters, :or) do
raise ArgumentError,
"array_agg/2 filter DSL is AND-only (does not support `or:` groups); " <>
"use `filter: dynamic(...)` or `filter: fn resolve -> ... end` for OR logic"
end
Enum.each(filters, fn
{:or, _} ->
raise ArgumentError,
"array_agg/2 filter DSL is AND-only (does not support `{:or, ...}` groups); " <>
"use `filter: dynamic(...)` or `filter: fn resolve -> ... end` for OR logic"
_ ->
:ok
end)
:ok
end
defp validate_array_agg_filter_opt!(filter) when is_tuple(filter) do
if tuple_size(filter) > 0 and elem(filter, 0) == :or do
raise ArgumentError,
"array_agg/2 filter DSL is AND-only (does not support `{:or, ...}` groups); " <>
"use `filter: dynamic(...)` or `filter: fn resolve -> ... end` for OR logic"
end
:ok
end
defp validate_array_agg_filter_opt!(other) do
raise ArgumentError,
"array_agg/2 expects `:filter` to be a keyword list, a list of filters, a filter tuple, a dynamic, or a 1-arity function; " <>
"got: #{inspect(other)}"
end
# ----------------------------------------------------------------------------
# Exists / Has Helpers
# ----------------------------------------------------------------------------
@doc ~S"""
A shorthand for `where_exists_subquery/3` (“has associated rows”).
It applies a correlated `EXISTS(...)` filter for the given association path.
`filters` are AND-ed inside the subquery.
Filter fields must be explicit association tokens (contain `@`), because the
predicate runs inside the association subquery.
Examples:
```elixir
# Equivalent to where_exists_subquery(:authored_articles, where: [published@authored_articles: true], scope: [])
User |> QueryBuilder.where_has(:authored_articles, published@authored_articles: true)
```
```elixir
User |> QueryBuilder.where_has([authored_articles: :comments], title@comments: "It's great!")
```
"""
def where_has(query, assoc_fields, filters \\ [])
def where_has(%QueryBuilder.Query{} = query, assoc_fields, filters) do
if assoc_fields in [nil, []] do
raise ArgumentError,
"where_has/3 requires a non-empty assoc_fields argument " <>
"(e.g. `where_has(:comments, ...)` or `where_has([articles: :comments], ...)`)"
end
if is_nil(filters) do
raise ArgumentError,
"where_has/3 expects filters to be a keyword list (or a list of filters); got nil"
end
filters = List.wrap(filters)
validate_where_has_filters!(filters, "where_has/3")
where_exists_subquery(query, assoc_fields, where: filters, scope: [])
end
def where_has(ecto_query, assoc_fields, filters) do
ecto_query = ensure_query_has_binding(ecto_query)
where_has(%QueryBuilder.Query{ecto_query: ecto_query}, assoc_fields, filters)
end
@doc ~S"""
A shorthand for `where_not_exists_subquery/3` (“missing associated rows”).
This is the `NOT EXISTS(...)` counterpart to `where_has/3`.
Filter fields must be explicit association tokens (contain `@`), because the
predicate runs inside the association subquery.
"""
def where_missing(query, assoc_fields, filters \\ [])
def where_missing(%QueryBuilder.Query{} = query, assoc_fields, filters) do
if assoc_fields in [nil, []] do
raise ArgumentError,
"where_missing/3 requires a non-empty assoc_fields argument " <>
"(e.g. `where_missing(:comments, ...)` or `where_missing([articles: :comments], ...)`)"
end
if is_nil(filters) do
raise ArgumentError,
"where_missing/3 expects filters to be a keyword list (or a list of filters); got nil"
end
filters = List.wrap(filters)
validate_where_has_filters!(filters, "where_missing/3")
where_not_exists_subquery(query, assoc_fields, where: filters, scope: [])
end
def where_missing(ecto_query, assoc_fields, filters) do
ecto_query = ensure_query_has_binding(ecto_query)
where_missing(%QueryBuilder.Query{ecto_query: ecto_query}, assoc_fields, filters)
end
@doc ~S"""
A correlated `EXISTS(...)` subquery filter.
This is the explicit alternative to `where/4` when filtering through to-many
associations would otherwise duplicate root rows (SQL join multiplication).
Example:
```
User
|> QueryBuilder.where_exists_subquery(
[authored_articles: :comments],
where: [title@comments: "It's great!"],
scope: []
)
|> Repo.all()
```
`scope:` is **required** to make the “new query block” boundary explicit. It is
applied inside the `EXISTS(...)` subquery (and is not inferred from outer joins).
Pass `scope: []` to explicitly declare “no extra scoping”.
Tuple filters inside the subquery must target association fields (use `field@assoc`).
Root-field filters belong on the outer query. For per-parent comparisons, use
field-to-field values via the `@self` marker (e.g. `{:inserted_at@articles, :gt, :inserted_at@self}`).
`where:` adds AND filters inside the subquery. To express OR groups, use
`where_any: [[...], ...]`.
"""
def where_exists_subquery(query, assoc_fields, opts \\ [])
def where_exists_subquery(%QueryBuilder.Query{} = query, assoc_fields, opts) do
if assoc_fields in [nil, []] do
raise ArgumentError,
"where_exists_subquery/3 requires a non-empty assoc_fields argument " <>
"(e.g. `where_exists_subquery(:comments, where: [..], scope: ..)` or `where_exists_subquery([articles: :comments], where: [..], scope: ..)`)"
end
{where_any, opts} = Keyword.pop(opts, :where_any, :__missing__)
{where_filters, opts} = Keyword.pop(opts, :where, [])
{scope, opts} =
case Keyword.pop(opts, :scope, :__missing__) do
{:__missing__, _} ->
raise ArgumentError,
"where_exists_subquery/3 requires an explicit `scope:` option; " <>
"pass `scope: []` to explicitly declare no extra scoping"
{scope, opts} when is_list(scope) ->
{scope, opts}
{other, _} ->
raise ArgumentError,
"where_exists_subquery/3 expects `scope:` to be a list of filters, got: #{inspect(other)}"
end
if is_nil(where_filters) or not is_list(where_filters) do
raise ArgumentError,
"where_exists_subquery/3 expects `where:` to be a list of filters; got: #{inspect(where_filters)}"
end
if Keyword.has_key?(opts, :or) do
raise ArgumentError,
"where_exists_subquery/3 does not support `or:`; use `where_any: [[...], ...]`"
end
validate_exists_subquery_filters!(
scope,
"where_exists_subquery/3 does not allow root field filters (no `@`) in `scope:`"
)
validate_exists_subquery_filters!(
where_filters,
"where_exists_subquery/3 does not allow root field filters (no `@`) in `where:`"
)
where_any_groups =
case where_any do
:__missing__ ->
[]
other ->
QueryBuilder.Filters.normalize_or_groups!(other, :where_any, "where_exists_subquery/3")
end
where_any_groups = Enum.reject(where_any_groups, &(&1 == []))
Enum.each(where_any_groups, fn group ->
validate_exists_subquery_filters!(
group,
"where_exists_subquery/3 does not allow root field filters (no `@`) in `where_any:`"
)
end)
{predicate_filters, predicate_or_filters} =
case where_any_groups do
[] -> {[], []}
[first | rest] -> {first, Enum.map(rest, &{:or, &1})}
end
effective_scope_filters = scope ++ where_filters
unknown_opt_keys =
opts
|> Keyword.keys()
|> Enum.uniq()
if unknown_opt_keys != [] do
raise ArgumentError,
"unknown options for where_exists_subquery/3: #{inspect(unknown_opt_keys)} " <>
"(supported: :where, :scope, :where_any)"
end
%{
query
| operations: [
{:where_exists_subquery, [],
[assoc_fields, effective_scope_filters, predicate_filters, predicate_or_filters]}
| query.operations
]
}
end
def where_exists_subquery(ecto_query, assoc_fields, opts) do
ecto_query = ensure_query_has_binding(ecto_query)
where_exists_subquery(
%QueryBuilder.Query{ecto_query: ecto_query},
assoc_fields,
opts
)
end
@doc ~S"""
A correlated `NOT EXISTS(...)` subquery filter.
Example:
```
User
|> QueryBuilder.where_not_exists_subquery(:authored_articles, where: [], scope: [])
|> Repo.all()
```
`where:` adds AND filters inside the subquery. To express OR groups, use
`where_any: [[...], ...]`.
"""
def where_not_exists_subquery(query, assoc_fields, opts \\ [])
def where_not_exists_subquery(%QueryBuilder.Query{} = query, assoc_fields, opts) do
if assoc_fields in [nil, []] do
raise ArgumentError,
"where_not_exists_subquery/3 requires a non-empty assoc_fields argument " <>
"(e.g. `where_not_exists_subquery(:comments, where: [..], scope: ..)` or `where_not_exists_subquery([articles: :comments], where: [..], scope: ..)`)"
end
{where_any, opts} = Keyword.pop(opts, :where_any, :__missing__)
{where_filters, opts} = Keyword.pop(opts, :where, [])
{scope, opts} =
case Keyword.pop(opts, :scope, :__missing__) do
{:__missing__, _} ->
raise ArgumentError,
"where_not_exists_subquery/3 requires an explicit `scope:` option; " <>
"pass `scope: []` to explicitly declare no extra scoping"
{scope, opts} when is_list(scope) ->
{scope, opts}
{other, _} ->
raise ArgumentError,
"where_not_exists_subquery/3 expects `scope:` to be a list of filters, got: #{inspect(other)}"
end
if is_nil(where_filters) or not is_list(where_filters) do
raise ArgumentError,
"where_not_exists_subquery/3 expects `where:` to be a list of filters; got: #{inspect(where_filters)}"
end
if Keyword.has_key?(opts, :or) do
raise ArgumentError,
"where_not_exists_subquery/3 does not support `or:`; use `where_any: [[...], ...]`"
end
where_any_groups =
case where_any do
:__missing__ ->
[]
other ->
QueryBuilder.Filters.normalize_or_groups!(
other,
:where_any,
"where_not_exists_subquery/3"
)
end
where_any_groups = Enum.reject(where_any_groups, &(&1 == []))
{predicate_filters, predicate_or_filters} =
case where_any_groups do
[] -> {[], []}
[first | rest] -> {first, Enum.map(rest, &{:or, &1})}
end
effective_scope_filters = scope ++ where_filters
unknown_opt_keys =
opts
|> Keyword.keys()
|> Enum.uniq()
if unknown_opt_keys != [] do
raise ArgumentError,
"unknown options for where_not_exists_subquery/3: #{inspect(unknown_opt_keys)} " <>
"(supported: :where, :scope, :where_any)"
end
%{
query
| operations: [
{:where_not_exists_subquery, [],
[assoc_fields, effective_scope_filters, predicate_filters, predicate_or_filters]}
| query.operations
]
}
end
def where_not_exists_subquery(ecto_query, assoc_fields, opts) do
ecto_query = ensure_query_has_binding(ecto_query)
where_not_exists_subquery(
%QueryBuilder.Query{ecto_query: ecto_query},
assoc_fields,
opts
)
end
# Migration shim: v1 accepted where_exists_subquery/4; v2 uses where_exists_subquery/3 opts.
def where_exists_subquery(_query, _assoc_fields, _filters, _opts) do
raise ArgumentError,
"where_exists_subquery/4 was replaced by where_exists_subquery/3; " <>
"use `where_exists_subquery(assoc_fields, where: [...], where_any: [[...], ...], scope: [...])`"
end
# Migration shim: v1 accepted where_not_exists_subquery/4; v2 uses where_not_exists_subquery/3 opts.
def where_not_exists_subquery(_query, _assoc_fields, _filters, _opts) do
raise ArgumentError,
"where_not_exists_subquery/4 was replaced by where_not_exists_subquery/3; " <>
"use `where_not_exists_subquery(assoc_fields, where: [...], where_any: [[...], ...], scope: [...])`"
end
# Migration shim: v1 used where_exists/4; v2 renamed it to where_exists_subquery/3.
def where_exists(_query, _assoc_fields, _filters, _or_filters \\ []) do
raise ArgumentError,
"where_exists/4 was renamed to where_exists_subquery/3; " <>
"use `where_exists_subquery(assoc_fields, where: [...], scope: [...])`"
end
# Migration shim: v1 used where_not_exists/4; v2 renamed it to where_not_exists_subquery/3.
def where_not_exists(_query, _assoc_fields, _filters, _or_filters \\ []) do
raise ArgumentError,
"where_not_exists/4 was renamed to where_not_exists_subquery/3; " <>
"use `where_not_exists_subquery(assoc_fields, where: [...], scope: [...])`"
end
@doc ~S"""
Run `QueryBuilder.where/2` only if given condition is met.
"""
def maybe_where(query, true, filters) do
where(query, [], filters)
end
def maybe_where(query, false, _), do: query
def maybe_where(query, condition, assoc_fields, filters, or_filters \\ [])
@doc ~S"""
Run `QueryBuilder.where/4` only if given condition is met.
"""
def maybe_where(query, true, assoc_fields, filters, or_filters) do
where(query, assoc_fields, filters, or_filters)
end
def maybe_where(query, false, _, _, _), do: query
# ----------------------------------------------------------------------------
# Distinct
# ----------------------------------------------------------------------------
@doc ~S"""
A distinct query expression.
When passed `true`/`false`, this sets `DISTINCT` for the current select expression.
You can also pass order_by-like expressions (tokens/directions) to generate
`DISTINCT ON (...)` on databases that support it (e.g. Postgres).
"""
def distinct(_query, nil) do
raise ArgumentError,
"distinct/2 expects a boolean or an order_by-like expression (tokens, lists/keyword lists); got nil"
end
def distinct(query, value) do
distinct(query, [], value)
end
def distinct(_query, _assoc_fields, nil) do
raise ArgumentError,
"distinct/3 expects a boolean or an order_by-like expression (tokens, lists/keyword lists); got nil"
end
def distinct(%QueryBuilder.Query{} = query, _assoc_fields, []) do
query
end
def distinct(%QueryBuilder.Query{} = query, assoc_fields, value) do
%{
query
| operations: [{:distinct, assoc_fields, [value]} | query.operations]
}
end
def distinct(ecto_query, assoc_fields, value) do
ecto_query = ensure_query_has_binding(ecto_query)
distinct(%QueryBuilder.Query{ecto_query: ecto_query}, assoc_fields, value)
end
@doc ~S"""
Ensures the query returns unique root rows (dedupe by root primary key).
This is primarily useful when you must join a to-many association (e.g. for
filtering/order_by) but still want a unique list of root structs.
Postgres-only: uses `DISTINCT ON (root_pk...)` (via Ecto distinct expressions).
In join-multiplying queries, `order_by` determines which joined row “wins” for
each root.
Notes:
- Requires the root schema to have a primary key.
- Requires a database that supports `DISTINCT ON` (Postgres).
- Cannot be combined with `preload_through_join` on to-many associations (it
would drop association rows).
"""
def distinct_roots(query, enabled \\ true)
def distinct_roots(%QueryBuilder.Query{} = query, true) do
%{
query
| operations: [{:distinct_roots, [], []} | query.operations]
}
end
def distinct_roots(%QueryBuilder.Query{} = query, false), do: query
def distinct_roots(%QueryBuilder.Query{} = query, []), do: query
def distinct_roots(%QueryBuilder.Query{}, nil) do
raise ArgumentError, "distinct_roots/2 does not accept nil; omit the call or pass true/false"
end
def distinct_roots(%QueryBuilder.Query{}, enabled) do
raise ArgumentError,
"distinct_roots/2 expects a boolean (or [] as a no-op), got: #{inspect(enabled)}"
end
def distinct_roots(ecto_query, enabled) do
ecto_query = ensure_query_has_binding(ecto_query)
distinct_roots(%QueryBuilder.Query{ecto_query: ecto_query}, enabled)
end
# ----------------------------------------------------------------------------
# Grouping & Having
# ----------------------------------------------------------------------------
@doc ~S"""
A group by query expression.
Example:
```
QueryBuilder.group_by(query, :category)
```
"""
def group_by(_query, nil) do
raise ArgumentError,
"group_by/2 expects a token, a list of tokens/expressions, a dynamic, or a 1-arity function; got nil"
end
def group_by(query, expr) do
group_by(query, [], expr)
end
def group_by(_query, _assoc_fields, nil) do
raise ArgumentError,
"group_by/3 expects a token, a list of tokens/expressions, a dynamic, or a 1-arity function; got nil"
end
def group_by(%QueryBuilder.Query{} = query, _assoc_fields, []) do
query
end
def group_by(%QueryBuilder.Query{} = query, assoc_fields, expr) do
%{
query
| operations: [{:group_by, assoc_fields, [expr]} | query.operations]
}
end
def group_by(ecto_query, assoc_fields, expr) do
ecto_query = ensure_query_has_binding(ecto_query)
group_by(%QueryBuilder.Query{ecto_query: ecto_query}, assoc_fields, expr)
end
@doc ~S"""
An AND having query expression.
Like `where`, but applied after grouping.
"""
def having(_query, nil) do
raise ArgumentError,
"having/2 expects `filters` to be a keyword list (or a list of filters); got nil"
end
def having(query, filters) do
having(query, [], filters)
end
def having(query, assoc_fields, filters, or_filters \\ [])
def having(_query, _assoc_fields, nil, _or_filters) do
raise ArgumentError,
"having/4 expects `filters` to be a keyword list (or a list of filters); got nil"
end
def having(_query, _assoc_fields, _filters, nil) do
raise ArgumentError,
"having/4 expects `or_filters` to be a keyword list like `[or: [...], or: [...]]`; got nil"
end
def having(%QueryBuilder.Query{} = query, _assoc_fields, [], []) do
query
end
def having(%QueryBuilder.Query{} = query, assoc_fields, filters, or_filters) do
%{
query
| operations: [
{:having, assoc_fields, [filters, or_filters]} | query.operations
]
}
end
def having(ecto_query, assoc_fields, filters, or_filters) do
ecto_query = ensure_query_has_binding(ecto_query)
having(%QueryBuilder.Query{ecto_query: ecto_query}, assoc_fields, filters, or_filters)
end
@doc ~S"""
An OR having query expression (an OR of AND groups).
"""
def having_any(query, or_groups) do
having_any(query, [], or_groups)
end
def having_any(query, assoc_fields, or_groups)
def having_any(%QueryBuilder.Query{} = query, assoc_fields, or_groups) do
or_groups =
QueryBuilder.Filters.normalize_or_groups!(
or_groups,
:having_any,
"having_any/2 and having_any/3"
)
case Enum.reject(or_groups, &(&1 == [])) do
[] ->
query
[first | rest] ->
or_filters = Enum.map(rest, &{:or, &1})
having(query, assoc_fields, first, or_filters)
end
end
def having_any(ecto_query, assoc_fields, or_groups) do
ecto_query = ensure_query_has_binding(ecto_query)
having_any(%QueryBuilder.Query{ecto_query: ecto_query}, assoc_fields, or_groups)
end
@doc ~S"""
An order by query expression.
Example:
```
QueryBuilder.order_by(query, asc: :lastname, asc: :firstname)
```
"""
def order_by(_query, nil) do
raise ArgumentError, "order_by/2 expects a keyword list; got nil"
end
def order_by(query, value) do
order_by(query, [], value)
end
@doc ~S"""
An order by query expression.
For more about the second argument, see `where/3`.
Example:
```
QueryBuilder.order_by(query, :articles, asc: :title@articles)
```
"""
def order_by(_query, _assoc_fields, nil) do
raise ArgumentError, "order_by/3 expects a keyword list; got nil"
end
def order_by(%QueryBuilder.Query{} = query, _assoc_fields, []) do
query
end
def order_by(%QueryBuilder.Query{} = query, assoc_fields, value) do
%{
query
| operations: [{:order_by, assoc_fields, [value]} | query.operations]
}
end
def order_by(ecto_query, assoc_fields, value) do
ecto_query = ensure_query_has_binding(ecto_query)
order_by(%QueryBuilder.Query{ecto_query: ecto_query}, assoc_fields, value)
end
@doc ~S"""
Run `QueryBuilder.order_by/2` only if given condition is met.
"""
def maybe_order_by(query, true, value) do
order_by(query, [], value)
end
def maybe_order_by(query, false, _), do: query
@doc ~S"""
Run `QueryBuilder.order_by/3` only if given condition is met.
"""
def maybe_order_by(query, true, assoc_fields, value) do
order_by(query, assoc_fields, value)
end
def maybe_order_by(query, false, _, _), do: query
@doc ~S"""
Wrap multiple arguments for use with `from_opts(..., mode: :full)`.
`from_opts` passes each `{operation, value}` as a single argument to
the operation (i.e. it calls `operation(query, value)`). Use `args/*` when you
need to call an operation with multiple arguments (like `order_by/3`,
`select/3`, `where/3`, or custom extension functions).
Examples:
```elixir
QueryBuilder.from_opts(User, [order_by: QueryBuilder.args(:role, asc: :name@role)], mode: :full)
QueryBuilder.from_opts(User, [where: QueryBuilder.args(:role, [name@role: "admin"])], mode: :full)
```
"""
def args(arg1, arg2), do: build_args!([arg1, arg2])
def args(arg1, arg2, arg3), do: build_args!([arg1, arg2, arg3])
def args(arg1, arg2, arg3, arg4), do: build_args!([arg1, arg2, arg3, arg4])
def args(args) when is_list(args), do: build_args!(args)
defp build_args!(args) do
cond do
args == [] ->
raise ArgumentError, "args/1 expects at least 2 arguments; got an empty list"
length(args) < 2 ->
raise ArgumentError,
"args/1 expects at least 2 arguments; " <>
"for a single argument, pass it directly to from_opts/2 instead"
Enum.any?(args, &is_nil/1) ->
raise ArgumentError,
"args/* does not accept nil arguments; omit the operation or pass [] instead"
true ->
%QueryBuilder.Args{args: args}
end
end
@doc ~S"""
A limit query expression.
If multiple limit expressions are provided, the last expression is evaluated
Example:
```
QueryBuilder.limit(query, 10)
```
"""
def limit(%QueryBuilder.Query{} = query, value) do
# Limit order must be maintained, similar to Ecto:
# - https://hexdocs.pm/ecto/Ecto.Query-macro-limit.html
%{query | operations: [{:limit, [], [value]} | query.operations]}
end
def limit(ecto_query, value) do
ecto_query = ensure_query_has_binding(ecto_query)
limit(%QueryBuilder.Query{ecto_query: ecto_query}, value)
end
@doc ~S"""
A offset query expression.
If multiple offset expressions are provided, the last expression is evaluated
Example:
```
QueryBuilder.offset(query, 10)
```
"""
def offset(%QueryBuilder.Query{} = query, value) do
# Offset order must be maintained, similar to Ecto:
# - https://hexdocs.pm/ecto/Ecto.Query.html#offset/3
%{query | operations: [{:offset, [], [value]} | query.operations]}
end
def offset(ecto_query, value) do
ecto_query = ensure_query_has_binding(ecto_query)
offset(%QueryBuilder.Query{ecto_query: ecto_query}, value)
end
# ----------------------------------------------------------------------------
# Ranking Helpers (Postgres)
# ----------------------------------------------------------------------------
@doc ~S"""
Keeps the top N rows per group (window-function helper).
This ranks rows with `row_number() OVER (PARTITION BY ... ORDER BY ...)` and
filters to `rn <= n`, while returning the original root rows.
Postgres-only: uses window functions for `n > 1`; uses `DISTINCT ON` for `n: 1`
when the query has no `distinct`.
Options:
- `:partition_by` (required) - a token or list of tokens/expressions
- `:order_by` (required) - a keyword list like `order_by/*`
- `:n` (required) - a positive integer
- `:disable_distinct_on?` (optional) - when `true` and `n: 1`, forces the window-function plan (no `DISTINCT ON`)
Notes:
- Requires the root schema to have a primary key (used as a deterministic tie-breaker, and to join back for window-function ranking).
- `order_by` must include the root primary key fields as a tie-breaker.
- Must be applied before `order_by/*` (apply the final ordering after `top_n_per/*`).
- Must be applied before `limit/2` and `offset/2`.
Examples:
```elixir
# latest order per user
Order
|> QueryBuilder.top_n_per(partition_by: [:user_id], order_by: [desc: :created_at, desc: :id], n: 1)
|> Repo.all()
```
"""
def top_n_per(query, opts) do
top_n_per(query, [], opts)
end
def top_n_per(query, assoc_fields, opts)
def top_n_per(%QueryBuilder.Query{}, _assoc_fields, nil) do
raise ArgumentError,
"top_n_per/2 and top_n_per/3 expect `opts` to be a keyword list, got: nil"
end
def top_n_per(%QueryBuilder.Query{} = query, assoc_fields, opts) when is_list(opts) do
%{
query
| operations: [{:top_n_per, assoc_fields, [opts]} | query.operations]
}
end
def top_n_per(%QueryBuilder.Query{}, _assoc_fields, opts) do
raise ArgumentError,
"top_n_per/2 and top_n_per/3 expect `opts` to be a keyword list, got: #{inspect(opts)}"
end
def top_n_per(ecto_query, assoc_fields, opts) do
ecto_query = ensure_query_has_binding(ecto_query)
top_n_per(%QueryBuilder.Query{ecto_query: ecto_query}, assoc_fields, opts)
end
@doc ~S"""
A shorthand for `top_n_per/2` with `n: 1`.
Accepts the same options as `top_n_per/2` (except `:n` is fixed to 1).
Postgres-only.
Example:
```elixir
# one latest post per subreddit
Post
|> QueryBuilder.first_per(partition_by: [:subreddit_id], order_by: [desc: :score, desc: :id])
|> Repo.all()
```
"""
def first_per(query, opts) do
first_per(query, [], opts)
end
def first_per(query, assoc_fields, opts)
def first_per(_query, _assoc_fields, nil) do
raise ArgumentError,
"first_per/2 and first_per/3 expect `opts` to be a keyword list, got: nil"
end
def first_per(query, assoc_fields, opts) when is_list(opts) do
case Keyword.fetch(opts, :n) do
:error ->
top_n_per(query, assoc_fields, Keyword.put(opts, :n, 1))
{:ok, 1} ->
top_n_per(query, assoc_fields, opts)
{:ok, other} ->
raise ArgumentError,
"first_per/2 is `top_n_per/2` with `n: 1`; got n: #{inspect(other)}"
end
end
def first_per(_query, _assoc_fields, opts) do
raise ArgumentError,
"first_per/2 and first_per/3 expect `opts` to be a keyword list, got: #{inspect(opts)}"
end
# ----------------------------------------------------------------------------
# Joins
# ----------------------------------------------------------------------------
@doc ~S"""
An inner join query expression.
This emits `INNER JOIN`s for the given association path. It is “just join”: it does
not apply filters.
Example:
```
QueryBuilder.inner_join(query, [authored_articles: :comments])
```
"""
def inner_join(query, assoc_fields)
def inner_join(_query, nil) do
raise ArgumentError, "inner_join/2 expects assoc_fields, got nil"
end
def inner_join(%QueryBuilder.Query{} = query, assoc_fields) do
QueryBuilder.JoinOps.inner_join(query, assoc_fields)
end
def inner_join(ecto_query, assoc_fields) do
ecto_query = ensure_query_has_binding(ecto_query)
inner_join(%QueryBuilder.Query{ecto_query: ecto_query}, assoc_fields)
end
@doc ~S"""
A join query expression.
Example:
```
QueryBuilder.left_join(query, :articles, title@articles: "Foo", or: [title@articles: "Bar"])
```
Notes:
- `left_join/4` only supports leaf associations (no nested assoc paths). For nested
paths, use `left_join_leaf/4` or `left_join_path/4`.
"""
def left_join(query, assoc_fields, filters \\ [], or_filters \\ [])
def left_join(%QueryBuilder.Query{} = query, assoc_fields, filters, or_filters) do
QueryBuilder.JoinOps.left_join(query, assoc_fields, filters, or_filters)
end
def left_join(ecto_query, assoc_fields, filters, or_filters) do
ecto_query = ensure_query_has_binding(ecto_query)
left_join(%QueryBuilder.Query{ecto_query: ecto_query}, assoc_fields, filters, or_filters)
end
@doc ~S"""
Left-joins the *leaf association* and uses inner joins to traverse intermediate
associations in a nested path.
This is the explicit version of the historical nested `left_join/4` behavior.
Example (INNER authored_articles, LEFT comments):
```elixir
User
|> QueryBuilder.left_join_leaf([authored_articles: :comments])
|> Repo.all()
```
"""
def left_join_leaf(query, assoc_fields, filters \\ [], or_filters \\ [])
def left_join_leaf(%QueryBuilder.Query{} = query, assoc_fields, filters, or_filters) do
QueryBuilder.JoinOps.left_join_leaf(query, assoc_fields, filters, or_filters)
end
def left_join_leaf(ecto_query, assoc_fields, filters, or_filters) do
ecto_query = ensure_query_has_binding(ecto_query)
left_join_leaf(%QueryBuilder.Query{ecto_query: ecto_query}, assoc_fields, filters, or_filters)
end
@doc ~S"""
Left-joins *every hop* in a nested association path (a full left-joined chain).
Example (LEFT authored_articles, LEFT comments):
```elixir
User
|> QueryBuilder.left_join_path([authored_articles: :comments])
|> Repo.all()
```
"""
def left_join_path(query, assoc_fields, filters \\ [], or_filters \\ [])
def left_join_path(%QueryBuilder.Query{} = query, assoc_fields, filters, or_filters) do
QueryBuilder.JoinOps.left_join_path(query, assoc_fields, filters, or_filters)
end
def left_join_path(ecto_query, assoc_fields, filters, or_filters) do
ecto_query = ensure_query_has_binding(ecto_query)
left_join_path(%QueryBuilder.Query{ecto_query: ecto_query}, assoc_fields, filters, or_filters)
end
# ----------------------------------------------------------------------------
# LATERAL Join Helpers (Postgres)
# ----------------------------------------------------------------------------
@doc ~S"""
Left-joins the latest row of a `has_many` association and selects `{root, assoc}`.
This is a helper for “parent rows + latest child row joined” without multiplying
parent rows.
Postgres-only: emits `LEFT JOIN LATERAL (...) LIMIT 1`.
Supported options:
- `order_by:` (required) - a keyword list like `order_by/2` (applied in the assoc subquery)
- `where:` (optional) - filters like `where/2` (applied in the assoc subquery)
- `child_assoc_fields:` (optional) - assoc tree to join inside the assoc subquery (to support tokens like `field@assoc`)
Notes:
- Only supports a single, direct `has_many` association (no nested paths).
- The `order_by:` must include the assoc schema primary key fields as a tie-breaker.
- This sets a custom select (`{root, assoc}`), so it cannot be used with `paginate_cursor/3` or `paginate_offset/3`.
Example:
```elixir
User
|> QueryBuilder.order_by(asc: :id)
|> QueryBuilder.left_join_latest(:authored_articles, order_by: [desc: :inserted_at, desc: :id])
|> Repo.all()
# => [{%User{}, %Article{} | nil}, ...]
```
"""
def left_join_latest(query, assoc_field, opts \\ [])
def left_join_latest(_query, nil, _opts) do
raise ArgumentError, "left_join_latest/3 expects an association field, got nil"
end
def left_join_latest(_query, _assoc_field, nil) do
raise ArgumentError, "left_join_latest/3 expects opts to be a keyword list, got nil"
end
def left_join_latest(%QueryBuilder.Query{} = query, assoc_field, opts)
when is_atom(assoc_field) and is_list(opts) do
if Enum.any?(query.operations, fn
{type, _assocs, _args}
when type in [:select, :select_merge, :left_join_latest, :left_join_top_n] ->
true
_ ->
false
end) do
raise ArgumentError,
"only one select expression is allowed in query; " <>
"call `left_join_latest/3` at most once and avoid mixing it with `select/*`, `select_merge/*`, or `left_join_top_n/3`"
end
%{
query
| operations: [
{:left_join_latest, [], [assoc_field, opts]} | query.operations
]
}
end
def left_join_latest(query, assoc_field, opts) when is_atom(assoc_field) and is_list(opts) do
ecto_query = ensure_query_has_binding(query)
left_join_latest(%QueryBuilder.Query{ecto_query: ecto_query}, assoc_field, opts)
end
def left_join_latest(_query, assoc_field, _opts) do
raise ArgumentError,
"left_join_latest/3 expects `assoc_field` to be an atom (direct association), got: #{inspect(assoc_field)}"
end
@doc ~S"""
Left-joins the top N rows of a `has_many` association and selects `{root, assoc}`.
This is a helper for “parent rows + top N child rows joined”, returning multiple
`{parent, child}` rows per parent.
Postgres-only: emits `LEFT JOIN LATERAL (...) LIMIT n`.
Supported options:
- `n:` (required) - a positive integer (applied as a `LIMIT` in the assoc subquery)
- `order_by:` (required) - a keyword list like `order_by/2` (applied in the assoc subquery)
- `where:` (optional) - filters like `where/2` (applied in the assoc subquery)
- `child_assoc_fields:` (optional) - assoc tree to join inside the assoc subquery (to support tokens like `field@assoc`)
Notes:
- Only supports a single, direct `has_many` association (no nested paths).
- The `order_by:` must include the assoc schema primary key fields as a tie-breaker.
- This multiplies parent rows (up to `n` rows per parent).
- This sets a custom select (`{root, assoc}`), so it cannot be used with `paginate_cursor/3` or `paginate_offset/3`.
Example:
```elixir
User
|> QueryBuilder.left_join_top_n(:authored_articles, n: 3, order_by: [desc: :inserted_at, desc: :id])
|> Repo.all()
# => [{%User{}, %Article{} | nil}, ...]
```
"""
def left_join_top_n(query, assoc_field, opts \\ [])
def left_join_top_n(_query, nil, _opts) do
raise ArgumentError, "left_join_top_n/3 expects an association field, got nil"
end
def left_join_top_n(_query, _assoc_field, nil) do
raise ArgumentError, "left_join_top_n/3 expects opts to be a keyword list, got nil"
end
def left_join_top_n(%QueryBuilder.Query{} = query, assoc_field, opts)
when is_atom(assoc_field) and is_list(opts) do
if Enum.any?(query.operations, fn
{type, _assocs, _args}
when type in [:select, :select_merge, :left_join_latest, :left_join_top_n] ->
true
_ ->
false
end) do
raise ArgumentError,
"only one select expression is allowed in query; " <>
"call `left_join_top_n/3` at most once and avoid mixing it with `select/*`, `select_merge/*`, or `left_join_latest/3`"
end
%{
query
| operations: [
{:left_join_top_n, [], [assoc_field, opts]} | query.operations
]
}
end
def left_join_top_n(query, assoc_field, opts) when is_atom(assoc_field) and is_list(opts) do
ecto_query = ensure_query_has_binding(query)
left_join_top_n(%QueryBuilder.Query{ecto_query: ecto_query}, assoc_field, opts)
end
def left_join_top_n(_query, assoc_field, _opts) do
raise ArgumentError,
"left_join_top_n/3 expects `assoc_field` to be an atom (direct association), got: #{inspect(assoc_field)}"
end
# ----------------------------------------------------------------------------
# from_opts
# ----------------------------------------------------------------------------
@doc ~S"""
Applies a keyword list of operations to a query.
By default, `from_opts/2` runs in **boundary mode**: it only allows a small
join-independent subset of operations so callers don’t need to know whether the
base query happens to join/preload anything.
To opt into the full power of `from_opts`, use `mode: :full`.
Examples:
```elixir
# boundary (default)
QueryBuilder.from_opts(query, [
where: [name: "John"],
order_by: [desc: :inserted_at],
limit: 50
])
# full (trusted internal usage)
QueryBuilder.from_opts(query, [
where: QueryBuilder.args(:role, [name@role: "admin"]),
preload_separate: :role
], mode: :full)
```
"""
def from_opts(query, opts) do
from_opts(query, opts, mode: :boundary)
end
def from_opts(query, opts, from_opts_opts) do
__from_opts__(query, opts, __MODULE__, from_opts_opts)
end
@doc false
def __from_opts__(query, opts, apply_module) do
__from_opts__(query, opts, apply_module, mode: :boundary)
end
@doc false
def __from_opts__(query, opts, apply_module, from_opts_opts) do
QueryBuilder.FromOpts.apply(query, opts, apply_module, from_opts_opts)
end
# Migration shim: v1 exposed from_list/2. Keep it to raise with a clear upgrade hint.
def from_list(_query, _opts) do
raise ArgumentError,
"from_list/2 was renamed to from_opts/2; please update your call sites"
end
defp validate_exists_subquery_filters!(filters, context) do
Enum.each(filters, fn
fun when is_function(fun) ->
:ok
{field, value} ->
validate_exists_subquery_filter_tuple!(field, value, context, {field, value})
{field, operator, value} ->
validate_exists_subquery_filter_tuple!(field, value, context, {field, operator, value})
{field, operator, value, operator_opts} ->
validate_exists_subquery_filter_tuple!(
field,
value,
context,
{field, operator, value, operator_opts}
)
_other ->
:ok
end)
end
defp validate_exists_subquery_filter_tuple!(field, value, context, filter_tuple)
when is_atom(field) or is_binary(field) do
token = to_string(field)
if not String.contains?(token, "@") and not exists_subquery_value_is_field?(value) do
raise ArgumentError,
"#{context}. Move root filters to the outer query (e.g. `QueryBuilder.where/2`). " <>
"Got: #{inspect(filter_tuple)}"
end
:ok
end
defp validate_exists_subquery_filter_tuple!(_field, _value, _context, _filter_tuple), do: :ok
defp exists_subquery_value_is_field?(val) when val in [nil, false, true], do: false
defp exists_subquery_value_is_field?(val) when is_atom(val) or is_binary(val) do
val |> to_string() |> String.ends_with?("@self")
end
defp exists_subquery_value_is_field?(_val), do: false
defp validate_where_has_filters!(filters, context) do
Enum.each(filters, fn
fun when is_function(fun) ->
:ok
{field, _value} ->
validate_where_has_field_token!(field, context)
{field, _operator, _value} ->
validate_where_has_field_token!(field, context)
{field, _operator, _value, _operator_opts} ->
validate_where_has_field_token!(field, context)
other ->
raise ArgumentError,
"#{context} got an invalid filter entry: #{inspect(other)}. " <>
"Expected `{field, value}`, `{field, operator, value}`, `{field, operator, value, operator_opts}`, " <>
"or a 1-arity function."
end)
end
defp validate_where_has_field_token!(field, context) when is_atom(field) or is_binary(field) do
token = to_string(field)
if not String.contains?(token, "@") do
raise ArgumentError,
"#{context} requires explicit association tokens (containing `@`) in filters; " <>
"got: #{inspect(field)}. " <>
"Example: `#{token}@assoc: value`."
end
:ok
end
defp validate_where_has_field_token!(other, context) do
raise ArgumentError,
"#{context} expects field tokens to be atoms or strings, got: #{inspect(other)}"
end
defp ensure_query_has_binding(%Ecto.Query{} = ecto_query) do
schema = QueryBuilder.Utils.root_schema(ecto_query)
binding = schema._binding()
root_as = ecto_query.from.as
binding_used? = Query.has_named_binding?(ecto_query, binding)
cond do
root_as == binding ->
ecto_query
is_nil(root_as) and binding_used? ->
raise ArgumentError,
"expected root query to have named binding #{inspect(binding)} (#{inspect(schema)}), " <>
"but that binding name is already used by another binding in the query (likely a join). " <>
"QueryBuilder relies on #{inspect(binding)} referring to the root schema. " <>
"Fix: rename the conflicting binding (avoid `as: #{inspect(binding)}` on joins), " <>
"or start from the schema module (e.g. `#{inspect(schema)}`) instead of a pre-joined query."
is_nil(root_as) ->
Ecto.Query.from(ecto_query, as: ^binding)
true ->
collision_hint =
if binding_used? do
" The query also has a non-root named binding #{inspect(binding)}, so QueryBuilder cannot add it to the root."
else
""
end
raise ArgumentError,
"expected root query to have named binding #{inspect(binding)} (#{inspect(schema)}), " <>
"but it already has named binding #{inspect(root_as)}." <>
" Use `from(query, as: ^#{inspect(binding)})` before passing it to QueryBuilder." <>
collision_hint
end
end
defp ensure_query_has_binding(query) when is_atom(query) do
if Code.ensure_loaded?(query) and function_exported?(query, :__schema__, 1) do
query
|> Ecto.Queryable.to_query()
|> ensure_query_has_binding()
else
raise ArgumentError,
"expected an Ecto.Queryable (schema module, Ecto.Query, or QueryBuilder.Query), got: #{inspect(query)}"
end
end
defp ensure_query_has_binding(query) do
if Ecto.Queryable.impl_for(query) do
query
|> Ecto.Queryable.to_query()
|> ensure_query_has_binding()
else
raise ArgumentError,
"expected an Ecto.Queryable (schema module, Ecto.Query, or QueryBuilder.Query), got: #{inspect(query)}"
end
end
end