Schemaless queries
Most queries in lode are written using schemas. For example, to retrieve all posts in a database, one may write:
repo.all(repo: r, schema: post_schema(), query: query.from("posts", "p"))
Where Ecto’s macro rewrites Repo.all(Post) into a query that selects every
schema field into a %Post{} struct, lode does the same job with the
schema’s load codec: the adapter returns each row as a name-keyed
Dict(String, Value), and schema.load turns that dict into your record (see
DESIGN.md in the repository). In fact, lode queries are already
schemaless by construction — query.from("posts", "p") names a table, not a
schema, and the schema only enters at the edges, when rows are loaded or
values dumped.
Although you might use schemas for most of your queries, lode also lets
you query without one. The schemaless read path is repo.query_raw, which
plays the role of both Ecto’s Repo.query and its schemaless
from "posts", select: [:title] queries: you write parameterized SQL with
positional $1, $2, … placeholders, and rows come back as name-keyed
Dict(String, Value) maps:
import lode/repo
import lode/value.{VInt, VString}
import gleam/dict
let assert Ok(rows) =
repo.query_raw(
repo: r,
sql: "SELECT title, body FROM posts WHERE visits >= $1",
params: [VInt(100)],
)
let assert [first, ..] = rows
let assert Ok(VString(title)) = dict.get(first, "title")
Because rows are keyed by column name rather than position, reads are
order-independent and projection-tolerant: select columns in any order, or
only a subset, and load each field by name. The typed way to do that is
schema.load_field, which runs the column through an LodeType — the same
machinery schema fields use — and treats an absent column as VNull, so a
projection that omits a nullable column still loads:
import lode/schema
import lode/types/primitive.{Absent, Present}
// `age` was not selected, so for a nullable read it loads as Absent
// rather than erroring.
let assert Ok(title) =
schema.load_field(row: first, name: "title", of: primitive.string())
let assert Ok(Absent) =
schema.load_field(
row: first,
name: "age",
of: primitive.nullable(primitive.integer()),
)
For required columns there are also the blunter schema.require_int and
schema.require_string, and schema.get(row, name) fetches the raw Value
if you want to pattern match yourself. This idiom — query_raw, then
load_field/require_* per column — is the library’s own; see
lode_sql/test/query_raw_test.gleam for it running against a live Postgres.
When Ecto sees from "posts", select: [:title, :body], it converts the field
list into a map or struct for you. lode’s equivalent is
repo.query_raw_as, which runs raw SQL and loads each row into a typed struct
via the schema’s load codec, in one call — as long as your SELECT covers
every column the codec requires:
let assert Ok(posts) =
repo.query_raw_as(
repo: r,
schema: post_schema(),
sql: "SELECT id, title, body FROM posts",
params: [],
)
(The load codec is a plain function carried on the Schema value, so you can
also apply it per row yourself — list.try_map(rows, post_schema().load) — when
working from a query_raw result directly.)
Two behavioral notes versus Ecto’s Repo.query: query_raw returns only the
rows (there is no num_rows/columns result struct — a statement with no
result set returns Ok([])), and it needs a SQL-speaking adapter: the
in-memory adapter returns an error (see
Testing with Lode).
Schemaless writes go through the query builder. Ecto changes a post’s title
without a schema using update: [set: [title: ^new_title]]; the lode
equivalent is repo.update_all with a set list of column/Value pairs. The
where condition needs a column reference — either build an ad-hoc typed
FieldRef (binding 0 is the from source) and use the typed combinators, or
assemble expr.Col/expr.Lit directly:
import lode/query
import lode/query/expr.{FieldRef}
import lode/types/primitive
import lode/value.{VString}
pub fn update_title(
r: Repo,
post_id: Int,
new_title: String,
) -> Result(Int, LodeError) {
let id_column = FieldRef(binding: 0, name: "id", lode_type: primitive.id())
let q =
query.from("posts", "p")
|> query.where(expr.eq(field: id_column, to: post_id))
repo.update_all(repo: r, query: q, set: [#("title", VString(new_title))])
}
The ad-hoc FieldRef carries an LodeType, so expr.eq(field: id_column, to: post_id) is still type-checked — you get the casting guarantees a schema
field would give, without declaring a schema.
Ecto’s update construct supports four commands: set, inc (atomic
increment), push and pull (array append/remove). repo.update_all_set
covers all of them via query.Assignments — and returns an affected count:
pub fn increment_page_views(r: Repo, post_id: Int) -> Result(Int, LodeError) { repo.update_all_set( repo: r, query: query.from("posts", "p") |> query.where(expr.BinOp(expr.Eq, expr.Col(0, "id"), expr.Lit(VInt(post_id)))), set: [query.inc("page_views", by: VInt(1))], ) }Set a column from an expression with
query.set_expr. One caveat: Postgres rejects two assignments to the same column in oneUPDATE, sopushandpullthe same column in separate calls. TASK:update-all-inc-push-pull
Let’s look at another example. Imagine you are writing a reporting view. It
may be counter-productive to think about how your existing application
schemas relate to the report being generated; it is often simpler to write a
query that returns only the data you need. In Ecto this is a schemaless
from u in "users", join: ..., select: %{...} query; the lode query
model does have joins, group-by and aggregates, but repo.all always pairs a
query with a schema for loading — so a report that fits no schema goes
through query_raw:
import lode/error.{type LodeError}
import lode/repo.{type Repo}
import lode/schema
import lode/value.{VTimestamp}
import gleam/list
import gleam/result
import gleam/time/timestamp.{type Timestamp}
pub type Report {
Report(user_id: Int, count: Int)
}
pub fn running_activities(
r: Repo,
start_at: Timestamp,
end_at: Timestamp,
) -> Result(List(Report), LodeError) {
use rows <- result.try(repo.query_raw(
repo: r,
sql: "
SELECT a.user_id, count(u.id) AS count
FROM users AS u
JOIN activities AS a ON a.user_id = u.id
WHERE a.start_at > $1 AND a.end_at < $2
GROUP BY a.user_id",
params: [VTimestamp(start_at), VTimestamp(end_at)],
))
list.try_map(rows, fn(row) {
use user_id <- result.try(schema.require_int(row, "user_id"))
use count <- result.try(schema.require_int(row, "count"))
Ok(Report(user_id:, count:))
})
}
The function above does not rely on schemas and returns only the data that
matters for the report. Notice the role Ecto’s type/2 plays here: in Ecto
you write type(^start_at, :naive_datetime) to tell the planner how to cast
an interpolated value. In lode the parameters are already runtime-tagged
Values, so the constructor is the type annotation — VTimestamp encodes
as timestamptz, VNaiveDatetime(date:, time:) as a zone-less timestamp,
and so on for every variant in lode/value.
By keeping the query model schemaless at its core and pushing schemas to the edges, lode makes queries with and without schemas equally accessible — and because queries and expressions are plain values rather than macros, it also makes dynamic queries (where fields, filters and ordering are not known upfront) ordinary code. See Dynamic queries.
insert_all, update_all and delete_all
Ecto allows all database operations to be expressed without a schema,
starting with Repo.insert_all, which accepts a bare table name and a list
of field/value lists:
MyApp.Repo.insert_all(
"posts",
[
[title: "hello", body: "world"],
[title: "another", body: "post"]
]
)
Schemaless
insert_all.repo.insert_all_rawbulk-inserts value lists into a bare table — no schema:let assert Ok(2) = repo.insert_all_raw( repo: r, source: "posts", columns: ["title", "body"], rows: [ [VString("hello"), VString("world")], [VString("another"), VString("post")], ], )It returns the inserted count;
insert_all_raw_returningreturns the inserted rows. The schema-basedrepo.insert_allstays for typed rows. TASK:schemaless-insert-all
Updates and deletes, by contrast, are fully schemaless: repo.update_all and
repo.delete_all take a query (no schema anywhere) and return the affected
count — no rows need loading, so no codec is needed:
// Use the ID to select which posts to act on
let id_column = FieldRef(binding: 0, name: "id", lode_type: primitive.id())
let post =
query.from("posts", "p")
|> query.where(expr.eq(field: id_column, to: id))
// Update the title of all matching posts
let assert Ok(1) =
repo.update_all(repo: r, query: post, set: [#("title", VString("new title"))])
// Delete all matching posts
let assert Ok(1) = repo.delete_all(repo: r, query: post)
The same is true of the aggregate reads: repo.count(repo:, query:) and
repo.exists(repo:, query:) take only a query, since a count or a boolean
needs no row loading either.
It is not hard to see how these operations map directly to their SQL
variants, keeping the database at your fingertips without the need to
intermediate every operation through schemas — and when even the query model
is in the way, query_raw is the SQL itself. For the schema-ful side of this
spectrum, see Data mapping and validation.