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 { columnRef, 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', columnRef('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', columnRef('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', columnRef('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 the FROM-side columns when neither is set.
Column references and result shape
Use columnRef('alias.column') for the right-hand column of eq, neq,
gt, gte, lt or lte. A plain string remains a literal value, even when
it contains a dot. References are serializable data; no SQL expression is
interpolated from the column name.
A comparison involving NULL is unknown and does not match, including when
wrapped in not(...). Use isNull when NULL itself is the intended match.
Without .selectJoined, the result contains only the FROM-side columns.
Use .selectJoined({ parentTitle: 'parents.title' }) to request joined values;
joined fields are not added as hidden alias.column keys.
The memory store executes inner/left joins, self-joins and aliased projections,
including transaction-local writes. Reactive reads track both joined tables;
a change to either side recomputes the result. Use a unique id in a reactive
joined projection so row patches can identify each result row.
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