Self-joins + aliased projections
Join a table to itself or to a CTE. selectJoined({...}) for aliased projections with full TypeScript inference.
When you need to reference the same table twice in one query —
parent/child trees, before/after comparisons, follower/followee
graphs — use .as(alias) + .innerJoin(table, alias, on).
For non-self joins (a table joined to a DIFFERENT table) the preferred pattern is eager-loading via relations. This page covers the cases where eager-load doesn't fit: self-joins, joining against a CTE, or joining against a dynamically- named table.
Quick start — self-join
import { eq, queryFor } from '@voltro/database'
// Posts and their parent posts
const tree = await ctx.store.query(
queryFor(database.posts).as('children')
.innerJoin(database.posts, 'parents', eq('parents.id', 'children.parentId'))
.selectJoined({
childId: 'children.id',
childTitle: 'children.title',
parentTitle: 'parents.title',
})
.descriptor,
)
// rows: Array<{ childId: string; childTitle: string; parentTitle: string }>Compiles to:
SELECT
"children"."id" AS "childId",
"children"."title" AS "childTitle",
"parents"."title" AS "parentTitle"
FROM "posts" AS "children"
INNER JOIN "posts" AS "parents" ON "parents"."id" = "children"."parentId".as(alias) — aliasing the FROM table
.as('children') emits FROM "posts" AS "children". Required when
combining with .innerJoin(...) so the JOIN's ON condition can
disambiguate columns from both sides.
.innerJoin() vs .leftJoin()
Same shape, different semantics:
.innerJoin(target, alias, on)— drops rows from the FROM side that have no JOIN-side match..leftJoin(target, alias, on)— keeps rows from the FROM side even when no JOIN-side match exists. The aliased row's columns will benullfor those rows.
Both have two overloads:
Pass a Table descriptor → full type inference
queryFor(database.posts).as('children')
.innerJoin(database.posts, 'parents', eq('parents.id', 'children.parentId'))
.selectJoined({
parentTitle: 'parents.title', // ← type inferred as string
})
// rows[0].parentTitle is typed `string`, not `unknown`The framework tracks the joined Table's row type in a hidden Joins
type parameter. .selectJoined({...}) reads it to resolve each
'alias.column' source to the joined column's actual type.
Pass a string name → opaque type fallback
queryFor(database.posts).as('children')
.innerJoin('some_cte', 'cte', eq('cte.userId', 'children.userId'))
.selectJoined({
fromCte: 'cte.value', // ← type stays `unknown`
})Use the string form for CTE references (the framework doesn't have the CTE's row type at compile time) or when joining against a name that's only known at runtime.
.selectJoined({...}) — picking columns
The chain method has TWO mutually-exclusive projection forms:
.select('col1', 'col2')— for FROM-side rows only. Result row type narrows toPick<RowOf, 'col1' | 'col2'>..selectJoined({outKey: 'alias.col', ...})— for queries with joins. Each spec entry maps a'<alias>.<column>'source to an output key on the result row.
Mixing isn't supported; pick one. The framework defaults to
SELECT * when neither is set (returns the FROM-side row).
Reading without .selectJoined
If you skip .selectJoined, the result row stays the FROM-side row
type. The joined columns ARE on the row at runtime (postgres returns
them flat with alias.col keys) but TypeScript doesn't see them.
For ad-hoc reads:
const rows = await ctx.store.query(
queryFor(database.posts).as('c')
.innerJoin(database.posts, 'p', eq('p.id', 'c.parentId'))
.descriptor,
)
// rows[0] is typed Post
// rows[0]['p.title'] exists at runtime but needs an explicit castUse .selectJoined({...}) for the typed path; the cast escape-hatch
is for one-off reads.
Cross-dialect
Standard SQL — INNER JOIN ... AS ... ON ... is supported on every
dialect we ship with identical syntax.
When NOT to use this
- Joining different tables related via FK — use
eager-loading with relations +
.with({}). More ergonomic, gives you the nested-object result shape, and reactively subscribes to the joined tables. - Walking trees more than 1 level deep — use a recursive CTE. Self-join only covers parent + immediate child.
See also
- Joins (eager-load) — the preferred way for relations-based joins
- Recursive CTE — for multi-level tree traversal
- Sub-queries — for the "rows where column matches another query" pattern