https://github.com/mathieuprog/query_builder
Compose Ecto queries without effort
https://github.com/mathieuprog/query_builder
ecto elixir elixir-lang phoenix query-builder
Last synced: 10 days ago
JSON representation
Compose Ecto queries without effort
- Host: GitHub
- URL: https://github.com/mathieuprog/query_builder
- Owner: mathieuprog
- License: apache-2.0
- Created: 2019-12-10T09:15:39.000Z (about 6 years ago)
- Default Branch: master
- Last Pushed: 2026-01-10T19:38:54.000Z (12 days ago)
- Last Synced: 2026-01-10T22:14:40.386Z (12 days ago)
- Topics: ecto, elixir, elixir-lang, phoenix, query-builder
- Language: Elixir
- Size: 650 KB
- Stars: 82
- Watchers: 2
- Forks: 8
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- Changelog: CHANGELOG.md
- Funding: .github/FUNDING.yml
- License: LICENSE
Awesome Lists containing this project
README
# Query Builder
QueryBuilder is a thin layer over Ecto that builds composable queries from plain Elixir data structures.
## Index
- [Key Features](#key-features)
- [Setup](#setup)
- [Feature Overview](#feature-overview)
- [Examples](#examples)
## 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.
```elixir
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)
```
To let external callers request preloads safely, expose explicit `include:` keys that the context allowlists via `includes:`:
```elixir
def list_users(opts \\ []) do
includes = [role: :role]
User
|> QueryBuilder.where(deleted: false)
|> QueryBuilder.from_opts(opts, includes: includes)
|> Repo.all()
end
# controller/resolver
list_users(where: [name: "Alice"], include: [:role])
```
For optional params, use `maybe_where/*` / `maybe_order_by/*` to conditionally apply clauses.
```elixir
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.
For example, QueryBuilder lets you express “OR of AND groups” directly as nested lists:
```elixir
# (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:
```elixir
# (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.
```elixir
User
|> QueryBuilder.order_by(:role, asc: :name@role, asc: :nickname)
|> Repo.all()
```
Ecto:
```elixir
User
|> join(:left, [u], r in assoc(u, :role))
|> order_by([u, r], asc: r.name, asc: u.nickname)
|> Repo.all()
```
Implicit joins created by QueryBuilder default to `LEFT`. If an association must exist, use a `!` join marker in `assoc_fields` (e.g. `:role!`) or call `inner_join/2` explicitly.
```elixir
# Only users that have a role named "admin"
User
|> QueryBuilder.where(:role!, name@role: "admin")
|> 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.
```elixir
nickname_query = "admin"
filters = [
{:nickname, :contains, nickname_query, [case: :i]},
{:inserted_at, :ge, from},
{:id, :in, ids}
]
User
|> QueryBuilder.where(filters)
|> Repo.all()
```
```elixir
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.
```elixir
# 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)
```
`paginate/3` is an alias for `paginate_cursor/3`. For offset/row pagination (no cursor), use `paginate_offset/3`.
### Higher‑Level Query Helpers
QueryBuilder also includes higher-level helpers that are verbose to write correctly in raw Ecto.
```elixir
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.
```elixir
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:
```elixir
def deps do
[
{:query_builder, "~> 2.1"}
]
end
```
## Feature Overview
### Operations
- Filtering: `where/*`, `where_any/*`, `maybe_where/*`
- Sorting: `order_by/*`, `maybe_order_by/*`
- Offset pagination: `limit/2`, `offset/2`
- Keyset pagination: `paginate/3` (cursor-based)
- Joins: `inner_join/2`, `left_join/4`, `left_join_leaf/4`, `left_join_path/4`
- To-many existence filters: `where_exists_subquery/3`, `where_not_exists_subquery/3`, `where_has/3`, `where_missing/3`
- Preloads: `preload_separate/2`, `preload_separate_scoped/3`, `preload_through_join/2`
- Selection & distinctness: `select/*`, `select_merge/*`, `distinct/*`, `distinct_roots/1` (Postgres-only)
- Grouping & aggregates: `group_by/*`, `having/*`, `having_any/*`, aggregates (`count/*`, `count_distinct/1`, `avg/1`, `sum/1`, `min/1`, `max/1`, `array_agg/*` (Postgres-only))
- Postgres query patterns: `top_n_per/*`, `first_per/*`, `left_join_latest/3`, `left_join_top_n/3`
### Tokens, assoc paths, and join intent
- Tokens are atoms/strings: `:field`, `:field@assoc`, or full paths like `:field@assoc@nested_assoc...`.
- `field@assoc` is shorthand and raises if `@assoc` is ambiguous; use a full-path token to disambiguate.
- Assoc paths (`assoc_fields`) support join markers:
- `:role` (neutral): reuse an existing join qualifier if already joined; otherwise QueryBuilder defaults to `LEFT`
- `:role?`: force `LEFT`
- `:role!`: force `INNER`
- `@self` marks field-to-field comparisons (e.g. `{:inserted_at@comments, :gt, :inserted_at@self}`).
### `from_opts`
- `from_opts/2` defaults to boundary mode (for controllers/resolvers): allowlists `where`, `where_any`, `order_by`, `limit`, `offset`.
- To expose preloads safely at the boundary, let callers pass `include: [...]` and pass an `includes:` allowlist to `from_opts/3` (contexts define the meaning of each include key).
- `from_opts/3` with `mode: :full` enables the full QueryBuilder surface (use when the caller knows the base query’s implementation/shape).
- `args/*` wraps multiple arguments for `from_opts(..., mode: :full)` (e.g. calling `where/4`, `order_by/3`, `select/3`, or extension ops).
### Extensions
- `QueryBuilder.Extension` lets you define an app-specific module that wraps QueryBuilder and adds custom operations you can call directly.
- If you use `from_opts` on that module, you must explicitly allowlist which custom operations are callable via `from_opts_full_ops: [...]` (full mode) and optionally `boundary_ops_user_asserted: [...]` (boundary mode).
### Utilities
- `new/1`: wrap an existing Ecto queryable into a `%QueryBuilder.Query{}`.
- `subquery/2`: build an `Ecto.SubQuery` using QueryBuilder operations (`from_opts(..., mode: :full)` + `Ecto.Query.subquery/1`).
- `default_page_size/0`: reads `config :query_builder, :default_page_size`.
## Examples
### Filter “has related rows” (to-many) without duplicates
Filter root rows through a to-many association via correlated `EXISTS(...)` without join-multiplying roots.
```elixir
alias QueryBuilder, as: QB
User
|> QB.where_has(:authored_articles, published@authored_articles: true)
|> Repo.all()
```
### Filter “missing related rows” (to-many)
Filter root rows through a to-many association via correlated `NOT EXISTS(...)`.
```elixir
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`.
```elixir
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`).
```elixir
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).
```elixir
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`).
```elixir
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`.
```elixir
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 ...)`).
```elixir
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.
```elixir
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.
```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)
```