Query Builder

QueryBuilder is a thin layer over Ecto that builds composable queries from plain Elixir data structures.

Index

Key Features

Controller/Resolver‑Driven Query Options

Controllers/GraphQL resolvers can pass filter/sort/page options into a single context list function via from_opts/2, without creating new context functions per option or writing custom option-handling logic in the context.

def list_users(opts \\ []) do
User
|> QueryBuilder.where(deleted: false)
|> QueryBuilder.from_opts(opts)
|> Repo.all()
end
# controller/resolver
list_users(where: [name: "Alice"], order_by: [desc: :inserted_at], limit: 50)

For optional params, use maybe_where/* / maybe_order_by/* to conditionally apply clauses.

def list_users(opts \\ []) do
include_deleted? = Keyword.get(opts, :include_deleted?, false)
qb_opts = Keyword.drop(opts, [:include_deleted?])
User
|> QueryBuilder.maybe_where(not include_deleted?, deleted: false)
|> QueryBuilder.maybe_order_by(not Keyword.has_key?(qb_opts, :order_by), desc: :inserted_at, desc: :id)
|> QueryBuilder.from_opts(qb_opts)
|> Repo.all()
end

Data‑Driven Query Composition

QueryBuilder lets you express complex filtering and composition as plain Elixir data, without positional binding gymnastics and without manually building dynamic/2 trees for the common cases.

QueryBuilder lets you express “OR of AND groups” directly as nested lists:

# (name == "Alice") OR (name == "Bob" AND deleted == false)
or_groups = [[name: "Alice"], [name: "Bob", deleted: false]]
User
|> QueryBuilder.where(active: true)
|> QueryBuilder.where_any(or_groups)
|> Repo.all()

In Ecto, when the OR groups come from runtime data (e.g. controller params), you typically have to reduce them into a dynamic/2 expression:

# (name == "Alice") OR (name == "Bob" AND deleted == false)
or_groups = [[name: "Alice"], [name: "Bob", deleted: false]]
or_dynamic =
Enum.reduce(or_groups, dynamic([u], false), fn group, or_acc ->
and_dynamic =
Enum.reduce(group, dynamic([u], true), fn {field, value}, and_acc ->
dynamic([u], ^and_acc and field(u, ^field) == ^value)
end)
dynamic([u], ^or_acc or ^and_dynamic)
end)
User
|> where([u], u.active == true)
|> where(^or_dynamic)

Assoc Queries Without Binding Boilerplate

QueryBuilder lets you reference association fields with @ tokens (e.g. :name@role) instead of manually writing joins and positional binding lists.

User
|> QueryBuilder.order_by(:role, asc: :name@role, asc: :nickname)
|> Repo.all()

Ecto:

User
|> join(:left, [u], r in assoc(u, :role))
|> order_by([u, r], asc: r.name, asc: u.nickname)
|> Repo.all()

Rich Filter DSL (Operators + Field Comparisons)

Beyond {field, value} equality, you can use {field, operator, value} for common operators (ranges, membership, text search). For field-to-field comparisons, use the @self marker as the value.

nickname_query = "admin"
filters = [
{:nickname, :contains, nickname_query, [case: :i]},
{:inserted_at, :ge, from},
{:id, :in, ids}
]
User
|> QueryBuilder.where(filters)
|> Repo.all()
User
|> QueryBuilder.where_exists_subquery([authored_articles: :comments],
scope: [],
where: [
{:body@comments, :contains, :nickname@self, [case: :insensitive]}
]
)
|> Repo.all()

Keyset/Cursor-Based Pagination

paginate/3 returns an opaque cursor derived from your order_by; pass it back unchanged to fetch the next/previous page.

# First page (no cursor)
pagination_opts = [page_size: 10]
%{paginated_entries: users, pagination: page} =
User
|> QueryBuilder.order_by(asc: :nickname, desc: :email)
|> QueryBuilder.paginate(Repo, pagination_opts)
# Next page: pass back the opaque cursor returned in pagination
pagination_opts =
Keyword.merge(pagination_opts,
cursor: page.cursor_for_entries_after,
direction: :after
)
%{paginated_entries: next_users, pagination: next_page} =
User
|> QueryBuilder.order_by(asc: :nickname, desc: :email)
|> QueryBuilder.paginate(Repo, pagination_opts)

Higher‑Level Query Helpers

QueryBuilder also includes higher-level helpers that are verbose to write correctly in raw Ecto.

alias QueryBuilder, as: QB
# Latest child row per parent
User
|> QB.left_join_latest(:authored_articles, order_by: [desc: :inserted_at, desc: :id])
|> Repo.all()
# => [{%User{}, %Article{} | nil}, ...]
# Top N rows per group
Post
|> QB.top_n_per(partition_by: [:subreddit_id], order_by: [desc: :score, desc: :id], n: 3)
|> Repo.all()

Custom, User-Defined Query Operations (Extension)

QueryBuilder.Extension lets you build an app-specific “QB module” that adds your own query operations on top of QueryBuilder.

defmodule MyApp.QB do
use QueryBuilder.Extension, from_opts_full_ops: [:where_initcap]
import Ecto.Query
def where_initcap(query, field, value) do
where(query, fn resolve ->
{field, binding} = resolve.(field)
dynamic([{^binding, x}], fragment("initcap(?)", field(x, ^field)) == ^value)
end)
end
end
# trusted/internal (full mode)
alias MyApp.QB
MyApp.User
|> QB.where_initcap(:name, "Alice")
|> Repo.all()

Setup

Add query_builder as a dependency:

def deps do
[
{:query_builder, "~> 2.0.0"}
]
end

Feature Overview

Operations

Tokens, assoc paths, and join intent

from_opts

Extensions

Utilities

Examples

Filter root rows through a to-many association via correlated EXISTS(...) without join-multiplying roots.

alias QueryBuilder, as: QB
User
|> QB.where_has(:authored_articles, published@authored_articles: true)
|> Repo.all()

Filter root rows through a to-many association via correlated NOT EXISTS(...).

alias QueryBuilder, as: QB
User
|> QB.where_missing(:authored_articles)
|> Repo.all()

Ensure unique roots after joining a to-many association (Postgres)

When you must join a to-many association and still want unique root rows (especially with limit/offset), use distinct_roots/1.

alias QueryBuilder, as: QB
User
|> QB.left_join(:authored_articles)
|> QB.order_by(asc: :id)
|> QB.order_by(:authored_articles, desc: :inserted_at@authored_articles, desc: :id@authored_articles)
|> QB.distinct_roots()
|> QB.offset(20)
|> QB.limit(10)
|> Repo.all()

Scoped separate preload (Ecto query-preload equivalent)

Preload a direct association with an explicit scope using a separate query (preload_separate_scoped/3).

alias QueryBuilder, as: QB
User
|> QB.preload_separate_scoped(:authored_articles,
where: [published: true],
order_by: [desc: :inserted_at]
)
|> Repo.all()

Join-scoped preload (preload only joined rows)

Preload an association through its join binding so preloaded rows reflect the join (including join on: filters).

alias QueryBuilder, as: QB
User
|> QB.left_join(:authored_articles, published@authored_articles: true)
|> QB.preload_through_join(:authored_articles)
|> Repo.all()

Nested join semantics: LEFT every hop vs INNER path + LEFT leaf

Choose whether intermediate hops in a nested path are INNER (left_join_leaf/4) or LEFT (left_join_path/4).

alias QueryBuilder, as: QB
# INNER authored_articles, LEFT comments
q1 = User |> QB.left_join_leaf([authored_articles: :comments])
# LEFT authored_articles, LEFT comments
q2 = User |> QB.left_join_path([authored_articles: :comments])

Grouping + HAVING with aggregate helpers

Group and filter groups using group_by/* + having/* with aggregate helpers like count/0.

alias QueryBuilder, as: QB
User
|> QB.group_by(:role, :name@role)
|> QB.having([{QB.count(:id), :gt, 10}])
|> QB.select({:name@role, QB.count(:id)})
|> Repo.all()

array_agg with DISTINCT, ORDER BY, and FILTER (Postgres)

Build grouped results with Postgres aggregates like array_agg (including FILTER (WHERE ...)).

alias QueryBuilder, as: QB
Article
|> QB.group_by(:author_id)
|> QB.select(%{
author_id: :author_id,
publisher_ids:
QB.array_agg(:publisher_id,
distinct?: true,
order_by: [asc: :publisher_id],
filter: [{:publisher_id, :ne, nil}]
)
})
|> Repo.all()

Build an IN (subquery) using QueryBuilder ops

Use subquery/2 to build an Ecto.SubQuery from QueryBuilder options and use it in filters.

alias QueryBuilder, as: QB
active_user_ids =
QB.subquery(User,
where: [active: true],
select: :id
)
Article
|> QB.where({:author_id, :in, active_user_ids})
|> Repo.all()

Top N children per parent (Postgres, LATERAL)

Fetch up to N association rows per parent via LEFT JOIN LATERAL and group {parent, child} rows in Elixir.

alias QueryBuilder, as: QB
rows =
User
|> QB.left_join_top_n(:authored_articles, n: 3, order_by: [desc: :inserted_at, desc: :id])
|> Repo.all()
top_articles_by_user_id =
Enum.group_by(rows, fn {u, _a} -> u.id end, fn {_u, a} -> a end)