Skip to content

Repository files navigation

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.

deflist_users(opts\\[])doUser|>QueryBuilder.where(deleted: false)|>QueryBuilder.from_opts(opts)|>Repo.all()end# controller/resolverlist_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::

deflist_users(opts\\[])doincludes=[role: :role]User|>QueryBuilder.where(deleted: false)|>QueryBuilder.from_opts(opts,includes: includes)|>Repo.all()end# controller/resolverlist_users(where: [name: "Alice"],include: [:role])

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

deflist_users(opts\\[])doinclude_deleted?=Keyword.get(opts,:include_deleted?,false)qb_opts=Keyword.drop(opts,[:include_deleted?])User|>QueryBuilder.maybe_where(notinclude_deleted?,deleted: false)|>QueryBuilder.maybe_order_by(notKeyword.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:

# (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),fngroup,or_acc->and_dynamic=Enum.reduce(group,dynamic([u],true),fn{field,value},and_acc->dynamic([u],^and_accandfield(u,^field)==^value)end)dynamic([u],^or_accor^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],rinassoc(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.

# 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.

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 paginationpagination_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.

aliasQueryBuilder,as: QB# Latest child row per parentUser|>QB.left_join_latest(:authored_articles,order_by: [desc: :inserted_at,desc: :id])|>Repo.all()# => [{%User{}, %Article{} | nil}, ...]# Top N rows per groupPost|>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.

defmoduleMyApp.QBdouseQueryBuilder.Extension,from_opts_full_ops: [:where_initcap]importEcto.Querydefwhere_initcap(query,field,value)dowhere(query,fnresolve->{field,binding}=resolve.(field)dynamic([{^binding,x}],fragment("initcap(?)",field(x,^field))==^value)end)endend# trusted/internal (full mode)aliasMyApp.QBMyApp.User|>QB.where_initcap(:name,"Alice")|>Repo.all()

Setup

Add query_builder as a dependency:

defdepsdo[{: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.

aliasQueryBuilder,as: QBUser|>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(...).

aliasQueryBuilder,as: QBUser|>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.

aliasQueryBuilder,as: QBUser|>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).

aliasQueryBuilder,as: QBUser|>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).

aliasQueryBuilder,as: QBUser|>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).

aliasQueryBuilder,as: QB# INNER authored_articles, LEFT commentsq1=User|>QB.left_join_leaf([authored_articles: :comments])# LEFT authored_articles, LEFT commentsq2=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.

aliasQueryBuilder,as: QBUser|>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 ...)).

aliasQueryBuilder,as: QBArticle|>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.

aliasQueryBuilder,as: QBactive_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.

aliasQueryBuilder,as: QBrows=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.idend,fn{_u,a}->aend)

About

Compose Ecto queries without effort

Topics

Resources

Stars

82 stars

Watchers

2 watching

Forks

Releases

Sponsor this project

Packages

Contributors

Languages