Aggregates and subqueries
This guide is about the two ways Ecto answers questions about sets of rows
rather than individual ones: aggregates (how many posts? what is the average
visit count?) and subqueries (questions whose answer depends on the result of
another query). Both translate to lode: aggregates via repo.count and
repo.aggregate, and subqueries as a FROM source (from_subquery), as
WHERE x IN (subquery), as WHERE EXISTS (...) with correlated references
(expr.outer), and as a JOIN source (query.join_subquery).
This guide is rigorous about which is which.
The running example is a posts table that records how many times each post
has been visited. Just enough schema to query:
import lode/schema.{type Schema}
import lode/types/primitive
import lode/value.{VInt, VString}
import gleam/dict
import gleam/result
pub type Post {
Post(id: Int, title: String, visits: Int)
}
pub fn post_schema() -> Schema(Post) {
schema.new(
source: "posts",
fields: [
schema.primary_key(name: "id", of: primitive.id()),
schema.field(name: "title", of: primitive.string()),
schema.field(name: "visits", of: primitive.integer()),
],
load: fn(row) {
use id <- result.try(schema.require_int(row, "id"))
use title <- result.try(schema.require_string(row, "title"))
use visits <- result.try(schema.require_int(row, "visits"))
Ok(Post(id:, title:, visits:))
},
dump: fn(p: Post) {
dict.from_list([
#("id", VInt(p.id)),
#("title", VString(p.title)),
#("visits", VInt(p.visits)),
])
},
)
}
Queries reference columns through typed accessors. Ecto generates these from
the schema’s macro; here they are plain functions (codegen can emit them — see
Getting Started). We need one for
visits:
import lode/query/expr.{type FieldRef, FieldRef}
fn post_visits() -> FieldRef(Post, Int) {
FieldRef(binding: 0, name: "visits", lode_type: primitive.integer())
}
Aggregates
Ecto offers Repo.aggregate/3, a one-call helper:
Repo.aggregate(Post, :count) for a row count and
Repo.aggregate(from(p in Post, where: p.visits > 0), :avg, :visits) for an
average. lode has both: repo.count for counts and repo.aggregate for
the scalar aggregates. We’ll take them in turn.
Counting
For counting rows there is repo.count:
import lode/query
import lode/repo.{type Repo}
import lode/error.{type LodeError}
pub fn post_count(r: Repo) -> Result(Int, LodeError) {
repo.count(repo: r, query: query.from(source: "posts", alias: "p"))
}
It composes with where, exactly as Ecto’s Repo.aggregate(query, :count)
does — count only the posts with visits, say:
pub fn visited_post_count(r: Repo) -> Result(Int, LodeError) {
repo.count(
repo: r,
query: query.from(source: "posts", alias: "p")
|> query.where(expr.gt(field: post_visits(), to: 0)),
)
}
repo.count pushes SELECT count(*) down to the database, so the server
returns a single number rather than the whole result set. There is also
repo.exists(repo:, query:) -> Result(Bool, LodeError) for the common “is there
at least one?” question (Ecto’s Repo.exists?).
Note — counting with
limit/group_by. A barecount(*)can’t honor alimit,offset,group_by, ordistinct, andrepo.countdoesn’t wrap the query in a subquery automatically — when the query carries any of those it falls back to materializing the rows and counting them in Gleam (correct, but it transfers the result set). To count a bounded set in the database, wrap it yourself:repo.count(r, query.from_subquery(bounded, "t")). Plain filtered counts (the common case) take the cheapcount(*)path directly.
Sum, average, min, and max
For the other aggregates there is repo.aggregate. You pass the aggregate
expression — expr.sum(field), expr.avg(field), expr.min(field),
expr.max(field), expr.count(field), or expr.count_all() — and get the
scalar back as an Option(Value) (None when the aggregate is SQL NULL, e.g.
sum/avg/min/max over zero rows):
import gleam/option.{type Option}
import lode/value.{type Value}
pub fn average_visits(r: Repo) -> Result(Option(Value), LodeError) {
let q =
query.from(source: "posts", alias: "p")
|> query.where(expr.gt(field: post_visits(), to: 0))
repo.aggregate(repo: r, query: q, by: expr.avg(post_visits()))
}
repo.aggregate renders the aggregate (avg(p."visits")) and reads the single
scalar back for you — no result schema required. sum/min/max of an Int
column come back as VInt; avg as a numeric (VFloat from the in-memory
adapter, Postgres numeric). Like count, it does not wrap the query in a
subquery, so any limit/offset/group_by on q is not honored.
When you’d rather have the result typed and named — because it’s read in
several places — select the aliased aggregate into a small result schema and
load it through repo.one:
pub type Avg {
Avg(value: Float)
}
fn avg_schema() -> Schema(Avg) {
schema.new(
source: "posts",
fields: [schema.field(name: "avg", of: primitive.float_type())],
load: fn(row) {
// `avg(int)` comes back as a numeric; read it as a float column.
case schema.get(row, "avg") {
Ok(value.VFloat(f)) -> Ok(Avg(value: f))
// Empty table: avg of no rows is NULL.
_ -> Ok(Avg(value: 0.0))
}
},
dump: fn(_a: Avg) { dict.new() },
)
}
pub fn average_visits_typed(r: Repo) -> Result(Option(Avg), LodeError) {
let q =
query.from(source: "posts", alias: "p")
|> query.where(expr.gt(field: post_visits(), to: 0))
|> query.select([expr.as_(expr.avg(post_visits()), "avg")])
repo.one(repo: r, schema: avg_schema(), query: q)
}
expr.as_(expr.avg(post_visits()), "avg") renders avg(p."visits") AS "avg",
and the loader reads the column by that name (and can defend against the NULL
of an empty set). For one-off scalars, or SQL the typed builder can’t render at
all (see the subqueries section), repo.query_raw with a hand-written
SELECT avg(...) works too — read the value out of the returned row by column
name.
Grouped aggregates
Aggregates earn their keep grouped. Ecto writes
from(p in Post, group_by: p.published, select: {p.published, count(p.id)});
here query.group_by(by:) and a multi-expression select do the same. Count
posts per visit count, say — group by visits, select the group key alongside
count(*):
query.from(source: "posts", alias: "p")
|> query.group_by(by: [expr.col(post_visits())])
|> query.select([expr.col(post_visits()), expr.count_all()])
That renders SELECT p."visits", count(*) FROM "posts" AS p GROUP BY p."visits". To read it back, use the same tiny-schema trick with two fields
(the group key and the count, aliased so they have stable names), or
repo.query_raw. And query.having(condition:) filters the groups — keep only
visit counts shared by more than one post:
query.from(source: "posts", alias: "p")
|> query.group_by(by: [expr.col(post_visits())])
|> query.having(expr.gt(field: post_visits(), to: 0))
|> query.select([expr.col(post_visits()), expr.count_all()])
having takes a boolean Expr exactly like where. (Filtering on the
aggregate itself — HAVING count(*) > 1 — isn’t expressible with the typed
comparison combinators, which compare a FieldRef against a literal; write
that condition with expr.fragment("count(*) > ?", [...]) or in raw SQL.)
Subqueries
A subquery answers a question whose input is the result of another query.
lode supports the two most common shapes directly in the typed builder: a
subquery as a FROM source, and WHERE x IN (subquery).
Aggregating over a bounded set (from_subquery)
Suppose you want the average visit count over only the two most-visited posts. The naive query is wrong:
SELECT avg(visits) FROM posts ORDER BY visits DESC LIMIT 2
SQL applies avg across the whole table, and LIMIT then truncates the
single-row aggregate result — aggregates ignore LIMIT. The fix is to limit
first in a subquery, then aggregate over that bounded set. In Ecto:
top = from(p in Post, order_by: [desc: p.visits], limit: 2)
Repo.aggregate(subquery(top), :avg, :visits)
In lode, query.from_subquery makes the inner query the source, and
repo.aggregate runs over it:
pub fn avg_visits_of_top_2(r: Repo) -> Result(Option(Value), LodeError) {
let top =
query.from(source: "posts", alias: "p")
|> query.order_by([query.desc(expr.col(post_visits()))])
|> query.limit(to: 2)
repo.aggregate(
repo: r,
query: query.from_subquery(top, "t"),
by: expr.avg(post_visits()),
)
}
This renders SELECT avg(t."visits") FROM (SELECT * FROM "posts" AS p ORDER BY p."visits" DESC LIMIT 2) AS t. The same shape wraps a distinct-ed or paginated
set: build the inner query, from_subquery it, then select or aggregate over the
result. The inner query’s $n parameters are threaded into the outer query’s
parameter sequence in textual order, so this stays fully typed and parameterized.
Membership (WHERE x IN (subquery))
query.where_in_subquery (and where_not_in_subquery) test a field against the
single column a subquery selects — Ecto’s where: x in subquery(q). The field
and the subquery’s selected column are ordinary typed accessors (expr.col(...),
like post_visits() above):
// posts written by an author who is currently active
let active_author_ids =
query.from(source: "users", alias: "u")
|> query.select([expr.col(user_id())])
|> query.where(expr.eq(user_active(), True))
query.from(source: "posts", alias: "p")
|> query.where_in_subquery(expr.col(post_author_id()), active_author_ids)
This renders ... WHERE p."author_id" IN (SELECT u."id" FROM "users" AS u WHERE (u."active" = $1)), again threading the subquery’s parameters into the outer
$n sequence.
Correlated / EXISTS subqueries
query.where_exists(q, subquery) / where_not_exists render WHERE EXISTS (...) / NOT EXISTS (...), and a subquery references the enclosing query’s
columns with expr.outer(alias, name) (Ecto’s parent_as):
let has_comment =
query.from("comments", "c")
|> query.where(expr.BinOp(
expr.Eq, expr.Col(0, "post_id"), expr.outer("p", "id")))
query.from("posts", "p") |> query.where_exists(has_comment)
// SELECT * FROM "posts" AS p WHERE EXISTS (
// SELECT * FROM "comments" AS c WHERE (c."post_id" = "p"."id"))
The outer alias renders verbatim (the subquery is lexically nested, so it’s in
scope), and the subquery’s $n parameters thread into the outer statement. A
subquery can also be a JOIN source — query.join_subquery(q, kind, subquery, alias, on) renders <kind> JOIN (<subquery>) AS alias ON <on>.
#correlated-subqueries
Window functions
expr.over(call, partition_by:, order_by:) projects a window application in
select (Ecto’s over/2) — the callee is a ranking function
(expr.row_number(), expr.rank(), expr.dense_rank()) or any aggregate
expression, and the window’s own ordering is built with
expr.win_asc/expr.win_desc:
query.from("scores", "s")
|> query.select([
expr.col(team()),
expr.as_(
expr.over(
expr.row_number(),
partition_by: [expr.col(team())],
order_by: [expr.win_desc(expr.col(points()))],
),
"rn",
),
])
// SELECT s."team", row_number() OVER (
// PARTITION BY s."team" ORDER BY s."points" DESC) AS "rn"
// FROM "scores" AS s
To reuse one window across several projections, declare it by name (Ecto’s
windows:): query.window(q, "w", partition_by:, order_by:) renders a
standard WINDOW w AS (...) clause between HAVING and ORDER BY, and
expr.over_named(expr.sum(points()), "w") projects sum(s."points") OVER w.
Both dialects render identical standard SQL — SQLite has had window functions
since 3.25, and the bundled amalgamation is 3.50.x. Frame clauses
(ROWS BETWEEN ...) are not modeled — drop to repo.query_raw for frames —
and window expressions are SQL-only: the in-memory adapter does not evaluate
them.
#window-functions
Common table expressions
query.with_cte(q, name:, as_:, recursive:) attaches a named CTE (Ecto’s
with_cte/3 — the label is as_ because as is a Gleam keyword), rendered as
a single WITH "name" AS (...) prefix before the statement. Reference the CTE
by name as an ordinary from/join source, and note the parameter order: the
CTE’s parameters render first, so the outer query’s own literals take the later
$n numbers:
let adults =
query.from("users", "u") |> query.where(expr.gt(user_age(), 18))
query.from("adults", "a")
|> query.with_cte(name: "adults", as_: adults, recursive: False)
|> query.where(expr.lt(adult_id(), 100))
// WITH "adults" AS (SELECT * FROM "users" AS u WHERE (u."age" > $1))
// SELECT * FROM "adults" AS a WHERE (a."id" < $2)
A recursive CTE’s body is base |> query.union_all(step), with the step
referencing the CTE’s own name as an ordinary join source (the recursion):
let base =
query.from("categories", "c") |> query.where(expr.is_nil(cat_parent()))
let step =
query.from("categories", "c")
|> query.join(query.InnerJoin, "tree", "t",
expr.eq_col(cat_parent(), tree_id()))
|> query.select([Col(0, "id"), Col(0, "parent_id"), Col(0, "name")])
query.from("tree", "t")
|> query.with_cte(name: "tree", as_: base |> query.union_all(step),
recursive: True)
// WITH RECURSIVE "tree" AS (
// SELECT * FROM "categories" AS c WHERE (c."parent_id" IS NULL)
// UNION ALL
// SELECT c."id", c."parent_id", c."name" FROM "categories" AS c
// INNER JOIN "tree" AS t ON (c."parent_id" = t."id"))
// SELECT * FROM "tree" AS t
Inside a CTE body, union operands render without parentheses — SQLite
rejects parenthesized compound operands, and the recursive form requires the
bare base UNION ALL step shape — so a CTE body’s union operands must not
carry their own order_by/limit. Plain and recursive CTEs work on both
engines (SQLite has had CTEs since 3.8.3). CTEs are SELECT-only
(update_all/delete_all ignore them), column aliases
(WITH name (cols) AS ...) are not modeled — name the columns with an explicit
select in the body — and the in-memory adapter does not evaluate CTEs; drop
to repo.query_raw for the unmodeled shapes.
#query-cte
Closing remarks
Aggregates are usable today: counts via repo.count (mind the materialization
caveat for limit/group_by), and sum/avg/min/max/grouped aggregates via
repo.aggregate or aggregate expressions in select. Subqueries are covered
in the typed builder in every position — a FROM source, WHERE x IN (...),
correlated EXISTS (via expr.outer), and a JOIN source. Window functions
are covered too — expr.over, or query.window + expr.over_named (see
Window functions above). CTEs are covered as well —
query.with_cte for plain and recursive WITH (see
Common table expressions above); window frames
are the remaining raw-SQL territory: use repo.query_raw/query_raw_as for
those.
#window-functions
#query-cte
For building filter and ordering logic at runtime, see Dynamic queries. For the full catalogue of what differs from Elixir’s Ecto, see Divergences from Ecto.