Dynamic queries
Elixir’s Ecto was designed around an expressive query API that leverages macros
to pre-compile queries for performance and safety. In Ecto you choose between a
keyword syntax and a pipe-based one, and values from the surrounding code are
interpolated with ^:
Post
|> where([p], p.author == "José" and p.category == "Elixir")
|> where([p], p.published_at > ^minimum_date)
|> order_by([p], desc: p.published_at)
lode has no macros, so none of that machinery exists — and none of it is
needed. There is exactly one syntax: plain function calls that build a Query
value. There are no [p] binding lists (bindings are positional integers
carried by typed field accessors), and there is no ^ interpolation (a value
from the surrounding code is just an argument). The same query reads:
import lode/query
import lode/query/expr.{type Expr, FieldRef}
import lode/types/primitive
import lode/types/temporal
import gleam/option.{type Option, None, Some}
import gleam/time/timestamp.{type Timestamp}
// Typed accessors for the `posts` source at binding 0. Hand-written here;
// `lode/codegen` emits these from the schema spec (see the
// getting-started.html guide).
fn post_author() -> FieldRef(Post, String) {
FieldRef(binding: 0, name: "author", lode_type: primitive.string())
}
fn post_category() -> FieldRef(Post, String) {
FieldRef(binding: 0, name: "category", lode_type: primitive.string())
}
fn post_published_at() -> FieldRef(Post, Timestamp) {
FieldRef(binding: 0, name: "published_at", lode_type: temporal.utc_datetime())
}
fn post_public() -> FieldRef(Post, Bool) {
FieldRef(binding: 0, name: "public", lode_type: primitive.boolean())
}
pub fn recent_elixir_posts(minimum_date: Timestamp) -> query.Query {
query.from(source: "posts", alias: "p")
|> query.where(expr.and_(
left: expr.eq(field: post_author(), to: "José"),
right: expr.eq(field: post_category(), to: "Elixir"),
))
|> query.where(expr.gt(field: post_published_at(), to: minimum_date))
|> query.order_by([query.desc(expr.col(post_published_at()))])
}
Queries compose the same way Ecto’s do — each call to query.where,
query.order_by, and so on returns a new Query, so abstracting the
published_at filtering and sorting into a function is ordinary code:
pub fn most_recent_from(q: query.Query, minimum_date: Timestamp) -> query.Query {
q
|> query.where(expr.gt(field: post_published_at(), to: minimum_date))
|> query.order_by([query.desc(expr.col(post_published_at()))])
}
This shows queries can be built and composed at a high level. Sometimes,
however, you want the contents of the where or the order_by themselves to
be decided at runtime. The classic example is a web application offering search
over posts: the user may specify any combination of criteria — author name,
category, a publication date cutoff, the sort order.
Ecto solves this with two extra mechanisms: data structures as query inputs and
the dynamic/2 macro. Let’s look at how each maps here.
Focusing on data structures
Ecto lets you ditch the binding and write where(author: "José", category: "Elixir") — keyword lists are accepted as first-class query inputs and can be
built dynamically.
Gleam has no keyword lists, but the deeper point needs no special support: in
lode everything is already a data structure. A Query is a record,
an Expr is a plain tree of constructors, and order_by takes a
List(query.OrderItem). Any of them can be produced by ordinary code. The
equivalent of feeding a runtime-built keyword list to where is mapping a list
of name/value pairs onto equality expressions with the raw expr.Col/expr.Lit
constructors:
import lode/value
import gleam/list
let conditions = [
#("author", value.VString("José")),
#("category", value.VString("Elixir")),
]
let order = [query.desc(expr.col(post_published_at()))]
let by_fields =
list.map(conditions, fn(c) {
expr.BinOp(expr.Eq, expr.Col(0, c.0), expr.Lit(c.1))
})
list.fold(by_fields, query.from(source: "posts", alias: "p"), query.where)
|> query.where(expr.gt(field: post_published_at(), to: minimum_date))
|> query.order_by(order)
The trade-offs mirror Ecto’s exactly. A pair list only encodes equality, so
order-based comparisons such as published_at > minimum_date are still written
with the typed combinators. And just as Ecto’s keyword lists are checked only
at runtime, dropping to expr.Col(0, "author") bypasses the typed accessor
layer — a typo’d column name or a wrongly-typed Value won’t be caught by the
compiler. Prefer the typed FieldRef accessors wherever the field is known
statically; the raw constructors are the deliberate escape hatch for truly
name-driven inputs (see schemaless-queries.html).
Dynamic fragments
For filters that can’t be expressed as data structures, Ecto provides the
dynamic/2 macro: it captures a query fragment as a value that can be built
conditionally and interpolated into a query later, e.g.
dynamic([p], p.published_at > ^date or p.public).
lode deliberately has no dynamic — because it doesn’t need one. Ecto
needs dynamic/2 to escape the macro world: a where argument is normally
consumed at compile time, so turning a fragment into a runtime value takes a
dedicated construct. In lode a query expression already is a runtime
value — Expr is a plain data type you can build in a function, store in a
variable, branch on, and pass around (see DESIGN.md in the repository). The
dynamic example above is simply:
let recent_or_public =
expr.or_(
left: expr.gt(field: post_published_at(), to: date),
right: expr.col(post_public()),
)
Note expr.col(post_public()) on its own: a reference to a boolean column is
itself a valid boolean expression, just like p.public in Ecto. Also note the
date comparison: post_published_at() is a FieldRef(Post, Timestamp) whose
lode_type is temporal.utc_datetime(), so expr.gt only accepts a
gleam/time Timestamp — passing a string is a compile error. At render time
the value dumps through the field’s type into a $n parameter
(p."published_at" > $1) that the Postgres adapter sends as a native
timestamp, never as interpolated text.
So where Ecto conditionally builds a dynamic, you conditionally build an
Expr. Suppose posts may optionally be filtered by publication date. You can
of course branch on the query itself:
let q =
query.from(source: "posts", alias: "p")
|> query.order_by(order)
let q = case published_before {
Some(date) -> query.where(q, expr.lt(field: post_published_at(), to: date))
None -> q
}
But you can also decouple the parameter handling from the query generation by
producing an Option(Expr) first and applying it later:
fn maybe_where(q: query.Query, condition: Option(Expr)) -> query.Query {
case condition {
Some(e) -> query.where(q, e)
None -> q
}
}
let filter_published_before = case published_before {
Some(date) -> Some(expr.lt(field: post_published_at(), to: date))
None -> None
}
query.from(source: "posts", alias: "p")
|> maybe_where(filter_published_before)
|> query.order_by(order)
(Ecto’s dynamic(true) seed has a literal translation too —
expr.Lit(value.VBool(True)) is an expression that renders as an
always-true parameter — but Option(Expr) skips the redundant clause
entirely.)
This decoupling is what makes complex search endpoints tractable. Let’s see a fuller example.
Building dynamic queries
Back to the original problem: a search function where the user configures how to traverse all posts — sort order, author and category filters, and a “published after” cutoff.
In Ecto the raw web params (a string-keyed map) are reduced directly into a
dynamic. Gleam has no string-keyed magic, so the idiomatic boundary is a
record of Option fields that the web layer decodes raw params into (parsing
the date with timestamp.parse_rfc3339, dropping unknown keys — the typed
analogue of upstream’s “not a where parameter” clause). From there, the
problem breaks into the same small functions, each returning plain data:
import gleam/list
import gleam/option
pub type PostSearch {
PostSearch(
order_by: String,
author: Option(String),
category: Option(String),
published_after: Option(Timestamp),
)
}
pub fn filter(params: PostSearch) -> query.Query {
query.from(source: "posts", alias: "p")
|> query.order_by(filter_order_by(params.order_by))
|> maybe_where(filter_where(params))
}
pub fn filter_order_by(order: String) -> List(query.OrderItem) {
case order {
"published_at_desc" -> [query.desc(expr.col(post_published_at()))]
"published_at" -> [query.asc(expr.col(post_published_at()))]
_ -> []
}
}
pub fn filter_where(params: PostSearch) -> Option(Expr) {
[
option.map(params.author, fn(name) {
expr.eq(field: post_author(), to: name)
}),
option.map(params.category, fn(c) {
expr.eq(field: post_category(), to: c)
}),
option.map(params.published_after, fn(date) {
expr.gt(field: post_published_at(), to: date)
}),
]
|> option.values
|> list.reduce(expr.and_)
|> option.from_result
}
Because the problem is broken into small functions over regular data, all of
Gleam’s tools apply. filter_order_by pattern matches on the requested order.
filter_where is the analogue of upstream’s Enum.reduce over an empty
dynamic: each present filter becomes an Expr, option.values keeps the ones
that apply, and list.reduce(expr.and_) folds them into one combined
condition (Error(Nil) from the empty list becomes None — no WHERE at
all). Swapping expr.and_ for expr.or_ would make the filters alternatives
instead.
Testing also becomes simpler, and here the value-based design pays off twice.
Ecto’s guide asserts on inspect(dynamic) strings; an Expr is plain data
with structural equality, so you assert on the value directly:
pub fn filter_where_published_after_test() {
let empty =
PostSearch(order_by: "", author: None, category: None, published_after: None)
// No filters -> no WHERE expression at all.
assert filter_where(empty) == None
let assert Ok(date) = timestamp.parse_rfc3339("2010-04-17T00:00:00Z")
assert filter_where(PostSearch(..empty, published_after: Some(date)))
== Some(expr.BinOp(
expr.Gt,
expr.Col(0, "published_at"),
expr.Lit(value.VTimestamp(date)),
))
}
You can also go one level further and assert on the rendered SQL plus its
parameters with the renderer from lode_sql (import lode/query/sql):
pub fn filter_renders_test() {
let assert Ok(date) = timestamp.parse_rfc3339("2010-04-17T00:00:00Z")
let params =
PostSearch(
order_by: "published_at_desc",
author: Some("José"),
category: None,
published_after: Some(date),
)
let #(rendered, values) = sql.to_sql(filter(params))
assert rendered
== "SELECT * FROM \"posts\" AS p"
<> " WHERE ((p.\"author\" = $1) AND (p.\"published_at\" > $2))"
<> " ORDER BY p.\"published_at\" DESC"
assert values == [value.VString("José"), value.VTimestamp(date)]
}
Running the query is then repo.all(repo: r, schema: post_schema(), query: filter(params)).
Dynamic and joins
Even joins can be tackled dynamically. Let’s make the two modifications from
the upstream guide: allow sorting by author name ("author_name" and
"author_name_desc"), and move authors into a separate table, which means the
author filter in filter_where now goes through a join.
In Ecto this is where named bindings (as: :authors) come in, so that
dynamics built far from the query can refer to the join. lode’s bindings
are positional integers instead: the from source is binding 0 and each join
takes the next index, and every FieldRef carries its binding. So referring to
the joined table from a helper function just means using accessors built at
binding 1:
fn post_author_id() -> FieldRef(Post, Int) {
FieldRef(binding: 0, name: "author_id", lode_type: primitive.integer())
}
// Accessors for the `authors` source at binding 1 (the first join).
fn author_id() -> FieldRef(Author, Int) {
FieldRef(binding: 1, name: "id", lode_type: primitive.id())
}
fn author_name() -> FieldRef(Author, String) {
FieldRef(binding: 1, name: "name", lode_type: primitive.string())
}
The final solution looks like this:
pub fn filter(params: PostSearch) -> query.Query {
query.from(source: "posts", alias: "p")
// 1. Add the join — `authors` becomes binding 1
|> query.join(
kind: query.InnerJoin,
source: "authors",
alias: "a",
on: expr.eq_col(left: post_author_id(), right: author_id()),
)
|> query.order_by(filter_order_by(params.order_by))
|> maybe_where(filter_where(params))
}
// 2. Order items may now reference the join binding
pub fn filter_order_by(order: String) -> List(query.OrderItem) {
case order {
"published_at_desc" -> [query.desc(expr.col(post_published_at()))]
"published_at" -> [query.asc(expr.col(post_published_at()))]
"author_name_desc" -> [query.desc(expr.col(author_name()))]
"author_name" -> [query.asc(expr.col(author_name()))]
_ -> []
}
}
// 3. The author clause now filters through the join
pub fn filter_where(params: PostSearch) -> Option(Expr) {
[
option.map(params.author, fn(name) {
expr.eq(field: author_name(), to: name)
}),
option.map(params.category, fn(c) {
expr.eq(field: post_category(), to: c)
}),
option.map(params.published_after, fn(date) {
expr.gt(field: post_published_at(), to: date)
}),
]
|> option.values
|> list.reduce(expr.and_)
|> option.from_result
}
Two honest differences from Ecto here. First, the binding agreement is by
convention, not by name: author_name() hard-codes binding 1, so it is only
correct when filter actually adds the authors join first — the same
contract Ecto spells as: :authors, but without a build-time check. If the
join is missing, the renderer falls back to a synthetic t1 alias and
Postgres rejects the query, whereas Ecto raises on a missing named binding
when the query is built. When composing joins programmatically,
query.last_binding(q) returns the index the most recent join received, and
because FieldRef is an ordinary record you can re-bind an accessor with a
record update (FieldRef(..author_name(), binding: n)) — generated accessors
all start at binding 0, so this is also how you point codegen output at a
join. Second:
Association-derived joins. Ecto’s
join(:inner, [p], assoc(p, :authors))derives the join’s source andONfrom the declared association.association.join(query, schema, name, kind, alias)does the same in lode — it reads the metadata off the association and appends the join, returning aResult(the schema is the query’sfrombinding). You can still spellquery.joinwith an explicitsource:/on:by hand when you want a join the associations don’t describe.#assoc-joins
Adding more filters in the future is a matter of adding a field to
PostSearch and one entry to the list in filter_where — the fold does the
rest. For the full map of what else differs from Ecto’s query layer
(subqueries, named bindings, and friends), see Divergences from Ecto.