Joins¶
To combine tables, tempest-db-js has join(...) — and the result is a composite
type: an object with one key per table, each one holding its typed row.
Step 1 — The basic join¶
Start from the base table with an alias, then join others:
import { join } from "tempest-db-js";
const q = join(User, "user")
.innerJoin(Order, "order", { "user.id": "order.userId" })
.where({ "order.status": "paid" });
The result of q is inferred as:
One key per alias, each one holding the full row of that table. No flattening of
columns, no name collisions (user.id and order.id coexist).
Step 2 — Typed alias.column refs¶
on, where, and orderBy use refs in the "alias.column" format — and they are
checked at compile time:
join(User, "user")
.innerJoin(Order, "order", { "user.id": "order.userId" }) // ✅ valid columns
.where({ "order.status": "paid" }) // ✅
.orderBy("order.amount", "desc"); // ✅
// ❌ error: `user.bogus` is not a column of User
join(User, "user").innerJoin(Order, "order", { "user.bogus": "order.userId" });
Step 3 — leftJoin and nullability¶
A leftJoin keeps the left-hand rows even without a match — so the right side can
be null. The type reflects that: the joined key becomes Row | null:
const q = join(User, "user").leftJoin(Order, "order", { "user.id": "order.userId" });
// result: { user: UserRow; order: OrderRow | null }[]
At execution, a row without a match brings order: null — and the type forces you
to handle it:
const rows = await session.execute(q).all();
for (const row of rows) {
if (row.order) {
console.log(row.user.name, row.order.amount); // `order` narrowed to OrderRow
} else {
console.log(row.user.name, "no orders");
}
}
Step 4 — Multiple joins¶
Chain as many as you need; each one adds a key to the composite type:
const q = join(User, "user")
.innerJoin(Order, "order", { "user.id": "order.userId" })
.leftJoin(Product, "product", { "order.productId": "product.id" });
// result: { user: UserRow; order: OrderRow; product: ProductRow | null }[]
How it works under the hood¶
The dialect compiles the columns with an alias ("user"."id" AS "user.id"), so
the driver's flat row is split back into { user: {...}, order: {...} },
coercing each side by its model. For leftJoin, when all of the right side's
columns come back null, that side becomes null.
Declarative relations as an alternative
Joins give you the composite type { user, order }. When you'd rather navigate
relations (user.posts, post.author) without writing the join by hand, use
hasMany/belongsTo + loadRelations — typed eager-loading, one query per
relation, no N+1. See Repository.
Current limitation of join where
The and/or/not combinators work in the join where, but the
typed-per-column operators don't apply there yet: the alias.column keys are
checked, and the value accepts an exact match or an operator, but without the
per-type restriction that select has. It's the next refinement for joins.
Recap¶
join(Model, alias)starts it;.innerJoin/.leftJoin(Model, alias, on)add to it.- The result is composite:
{ [alias]: Row }, one key per table. leftJoinmakes the side nullable (Row | null) — the type forces you to handle it.on/where/orderByuse typedalias.columnrefs.
You've covered tempest-db-js's main path! See the Reference for the full API and the Roadmap for what's coming (migrations, relations).