- Allowlisted includes in
from_opts/3: contexts can exposeinclude:keys via anincludes:allowlist. paginate/3is now an alias forpaginate_cursor/3; offset/row pagination ispaginate_offset/3.
Example (allowlisted includes for controllers/resolvers):
def list_users(opts \\ []) do
User
|> QueryBuilder.where(deleted: false)
|> QueryBuilder.from_opts(opts, includes: [role: :role])
|> Repo.all()
end
# controller/resolver
list_users(where: [name: "Alice"], include: [:role])Supported includes: allowlist entries:
role: :role(shortcut for{:preload_separate, :role})articles_with_comments: [authored_articles: :comments](shortcut for{:preload_separate, [authored_articles: :comments]})role: {:preload_separate, :role}published_authored_articles: {:preload_separate_scoped, :authored_articles, opts}featured_authored_articles: {:preload_through_join, :authored_articles}(requires the assoc to be joined)
- The
:assoc_fieldsoption has been removed fromuse QueryBuilder(association bindings are now generated automatically). from_list/2was renamed tofrom_opts/2.from_opts/2defaults to boundary mode (onlywhere,where_any,order_by,limit,offsetare allowed); usefrom_opts/3withmode: :fullfor the full surface.preload/2is removed; usepreload_separate/2,preload_separate_scoped/3, orpreload_through_join/2.- Implicit association joins default to
LEFT(useinner_join/2or:assoc!markers when you needINNER). left_join/4no longer accepts nested association paths; useleft_join_leaf/4orleft_join_path/4.- Removed the authorizer hook.
Why boundary from_opts/2 disallows preloads
from_opts/2 exists mainly so external callers (controllers/resolvers) can pass query options like filtering, sorting, and pagination.
Preloads are excluded because letting an external caller request preloads is unsafe and can yield unexpected association data: what gets loaded can depend on how the context function built the base query (joins vs EXISTS, scoped joins, etc.). In other words, callers would need to know the context function’s implementation details to know whether a given preload is safe and what it will actually return.
Example (EXISTS root filter + preload overfetch):
Assume Alice has:
- Article A: tenant_id=1, published=true
- Article B: tenant_id=1, published=false
- Article C: tenant_id=2, published=true
Context wants: “users who have a published article in tenant 1”:
def list_users_with_published_article(tenant_id, opts \\ []) do
User
|> QueryBuilder.where_exists_subquery(:authored_articles,
scope: [],
where: [tenant_id@authored_articles: tenant_id, published@authored_articles: true]
)
|> QueryBuilder.from_opts(opts)
|> Repo.all()
endIf boundary allowed preloading :authored_articles, the meaning would be ambiguous: does the caller want all authored_articles, or only the ones matching the context’s “published in tenant 1” constraint? A separate preload always loads the full association for the returned users, so Alice would come back with A, B, and C (including unpublished and other-tenant rows). If we instead tried to preload the filtered subset through joins, the outcome would depend on how the context built the base query (scoped JOIN vs EXISTS).
Because external callers shouldn’t need to know those implementation details, preloading should be handled via explicit context-owned include options (full vs scoped). The caller can then make intent explicit by requesting include: [:authored_articles_all] vs include: [:authored_articles_published] (or whatever include names the context exposes).
Examples of exposing explicit include options handled inside the context (strategy + scope is part of the context contract):
def list_users(tenant_id, opts \\ []) do
{include, qb_opts} = Keyword.pop(opts, :include, [])
query =
User
|> QueryBuilder.where(tenant_id: tenant_id)
|> QueryBuilder.from_opts(qb_opts)
query =
if :role in include do
QueryBuilder.preload_separate(query, :role)
else
query
end
Repo.all(query)
end
def list_users_with_published_article(tenant_id, opts \\ []) do
{include, qb_opts} = Keyword.pop(opts, :include, [])
query =
User
|> QueryBuilder.where_exists_subquery(:authored_articles,
scope: [],
where: [tenant_id@authored_articles: tenant_id, published@authored_articles: true]
)
|> QueryBuilder.from_opts(qb_opts)
if :published_authored_articles in include do
QueryBuilder.preload_separate_scoped(query, :authored_articles,
where: [tenant_id: tenant_id, published: true],
order_by: [desc: :inserted_at]
)
|> Repo.all()
else
Repo.all(query)
end
endIf the association selection depends on parent fields (correlated predicates), use join-preload explicitly:
def list_users_having_featured_article(tenant_id, opts \\ []) do
{include, qb_opts} = Keyword.pop(opts, :include, [])
featured_filters = [
tenant_id@authored_articles: tenant_id,
published@authored_articles: true,
title@authored_articles: :nickname@self
]
base =
User
|> QueryBuilder.where(tenant_id: tenant_id)
|> QueryBuilder.from_opts(qb_opts)
if :featured_authored_articles in include do
base
|> QueryBuilder.inner_join(:authored_articles)
|> QueryBuilder.where(:authored_articles, featured_filters)
|> QueryBuilder.preload_through_join(:authored_articles)
|> Repo.all()
else
base
|> QueryBuilder.where_exists_subquery(:authored_articles,
scope: [],
where: featured_filters
)
|> Repo.all()
end
endWhy did implicit associations change from INNER to LEFT?
When QueryBuilder needs to join an association implicitly (because you reference assoc fields via field@assoc), it now uses LEFT JOIN by default. Previously, implicit INNER JOIN could silently drop root rows for optional associations; especially surprising with OR logic (where_any/* / having_any/*).
Example:
# Alice has no role; Bob has role "admin"
User
|> QB.where_any([[name: "Alice"], [name@role: "admin"]])
|> Repo.all()
# previously => [Bob] (Alice was dropped by the implicit INNER JOIN)
# now => [Alice, Bob]If you need “association must exist”, make it explicit with inner_join/2 or a join marker like :role!.
where_exists_subquery/3/where_not_exists_subquery/3: filter roots through associations using correlatedEXISTS/NOT EXISTS.where_has/3/where_missing/3: shorthands for commonEXISTS/NOT EXISTScases.distinct_roots/1: de-duplicate root rows by primary key (PostgresDISTINCT ON).array_agg/1/array_agg/2: Postgresarray_aggaggregate (supportsDISTINCT+ORDER BY+FILTER).top_n_per/2/first_per/2: top N (or first) row per group.left_join_latest/3: left-join the latesthas_manyrow per parent and select{root, assoc}.left_join_top_n/3: left-join the top Nhas_manyrows per parent and select{root, assoc}.- Full-path tokens (
field@assoc@nested_assoc...): disambiguate in case of ambiguity. - Join markers (
:assoc?/:assoc!): declare optional vs required association joins (LEFTvsINNER).
A large number of bugs and edge cases have been fixed.