Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ A from-scratch type-safe ORM and a lab for the relational designs that break nai

## What this demonstrates

Most ORM tutorials stop at `findMany`. This repo goes the other way. It builds the internals of a small ORM (query builder, parameter binding, later migrations, relations, and a unit of work) and then uses that machinery against schemas that are actually hard: multi-tenant isolation, bitemporal history, trees, money, and soft delete. The point is to see where typed query construction earns its keep and where you should write SQL by hand.
Most ORM tutorials stop at `findMany`. This repo goes the other way. It builds the internals of a small ORM (query builder, parameter binding, migrations, relations, and later a unit of work) and then uses that machinery against schemas that are actually hard: multi-tenant isolation, bitemporal history, trees, money, and soft delete. The point is to see where typed query construction earns its keep and where you should write SQL by hand.

## Concepts demonstrated

Expand All @@ -22,11 +22,17 @@ Most ORM tutorials stop at `findMany`. This repo goes the other way. It builds t
- Migration journal with a zero-padded id prefix invariant
- Dialect type mapping (`BOOLEAN`/`TIMESTAMPTZ` vs SQLite `INTEGER`/`TEXT`)
- Expand/contract: in-place column ALTER is rejected; drop and add instead
- N+1 query problem versus batched eager loading
- `hasMany` / `belongsTo` cardinality (1:N nested arrays, N:1 nullable parent)
- Hash join in the client: `IN (...)` then group by foreign key
- Identity map so two children share one parent object
- JOIN cartesian product vs two-query nesting (parent rows are not duplicated)

## What's implemented

- Type-safe SELECT/WHERE/JOIN query builder with prepared-statement binding
- Declarative schema DSL + migration runner with up/down and a diff generator
- hasMany/belongsTo relations with eager loading to kill the N+1 problem

## Usage

Expand Down Expand Up @@ -68,6 +74,23 @@ const to = defineSchema({
new Migrator(session, 'sqlite').migrateUp([{ id: '001_init', up: diffSchema(from, to), down: diffSchema(to, from) }])
```

Relations are declared once. Loading N parents then looping `WHERE userId = parent.id` is N extra queries. `eagerHasMany` compiles one `WHERE userId IN (...)` with unique keys, then nests children in memory. `belongsTo` does the inverse and reuses one parent object per id.

```ts
import { belongsTo, eagerHasMany, hasMany } from 'orm-workbench'

const userOrders = hasMany('orders', users, orders, users.id, orders.userId)
const orderUser = belongsTo('user', orders, users, orders.userId, users.id)

const graph = eagerHasMany(session, userOrders, parents, {
id: orders.id,
userId: orders.userId,
totalCents: orders.totalCents,
}, { dialect: 'sqlite', orderBy: orders.id })
```

A JOIN of users to orders repeats every user column once per order. The two-query path keeps parent rows unique and attaches `orders: []` when a user has none.

```bash
pnpm install
pnpm run typecheck
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export * from './schema'
export * from './query'
export * from './ddl'
export * from './migrate'
export * from './relations'
205 changes: 205 additions & 0 deletions src/relations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
import type { SqlSession } from './migrate'
import { from, inList, type Dialect, type InferRow, type PreparedQuery } from './query'
import { quoteIdent, type AnyTable, type ColRef } from './schema'

export type HasManyRel<As extends string = string> = {
readonly kind: 'hasMany'
readonly as: As
readonly parent: AnyTable
readonly child: AnyTable
readonly local: ColRef<unknown>
readonly foreign: ColRef<unknown>
}

export type BelongsToRel<As extends string = string> = {
readonly kind: 'belongsTo'
readonly as: As
readonly child: AnyTable
readonly parent: AnyTable
readonly local: ColRef<unknown>
readonly foreign: ColRef<unknown>
}

export type Relation = HasManyRel<string> | BelongsToRel<string>

type SelectShape = Record<string, ColRef<unknown>>

export type RelatedQueryOpts = {
dialect?: Dialect
orderBy?: ColRef<unknown>
dir?: 'asc' | 'desc'
}

function requireCol(table: AnyTable, col: ColRef<unknown>, role: string): void {
if (col.table !== table.$alias) {
throw new Error(`${role} ${col.table}.${col.column} is not on ${table.$alias}`)
}
if (!(col.column in table.$columns)) {
throw new Error(`${role} unknown column ${table.$alias}.${col.column}`)
}
}

function colKind(table: AnyTable, col: ColRef<unknown>): string {
const def = table.$columns[col.column]
if (!def) throw new Error(`Unknown column ${table.$alias}.${col.column}`)
return def.kind
}

function declareRel(
as: string,
owner: AnyTable,
localTable: AnyTable,
local: ColRef<unknown>,
foreignTable: AnyTable,
foreign: ColRef<unknown>,
): void {
quoteIdent(as)
if (as in owner.$columns) {
throw new Error(`Relation ${JSON.stringify(as)} collides with a column on ${owner.$name}`)
}
requireCol(localTable, local, 'local')
requireCol(foreignTable, foreign, 'foreign')
const left = colKind(localTable, local)
const right = colKind(foreignTable, foreign)
if (left !== right) {
throw new Error(
`Relation columns ${local.table}.${local.column} (${left}) and ${foreign.table}.${foreign.column} (${right}) differ`,
)
}
}

export function hasMany<As extends string>(
as: As,
parent: AnyTable,
child: AnyTable,
local: ColRef<unknown>,
foreign: ColRef<unknown>,
): HasManyRel<As> {
declareRel(as, parent, parent, local, child, foreign)
return { kind: 'hasMany', as, parent, child, local, foreign }
}

export function belongsTo<As extends string>(
as: As,
child: AnyTable,
parent: AnyTable,
local: ColRef<unknown>,
foreign: ColRef<unknown>,
): BelongsToRel<As> {
declareRel(as, child, child, local, parent, foreign)
return { kind: 'belongsTo', as, child, parent, local, foreign }
}

export function uniqueKeys(values: readonly unknown[]): unknown[] {
const seen = new Set<unknown>()
const out: unknown[] = []
for (const value of values) {
if (value === null || value === undefined) continue
if (seen.has(value)) continue
seen.add(value)
out.push(value)
}
return out
}

function relatedTable(rel: Relation): AnyTable {
return rel.kind === 'hasMany' ? rel.child : rel.parent
}

function assertJoinProjected(shape: SelectShape, col: ColRef<unknown>): void {
const projected = shape[col.column]
if (!projected || projected.table !== col.table || projected.column !== col.column) {
throw new Error(`Select must project ${col.table}.${col.column} as ${col.column} to nest related rows`)
}
}

function readKey(row: Record<string, unknown>, column: string, table: string): unknown {
if (!(column in row)) {
throw new Error(`Row is missing ${table}.${column}; project that column under its own name`)
}
return row[column]
}

export function relatedQuery<S extends SelectShape>(
rel: Relation,
keys: readonly unknown[],
shape: S,
opts: RelatedQueryOpts = {},
): PreparedQuery<InferRow<S>> {
const table = relatedTable(rel)
assertJoinProjected(shape, rel.foreign)
if (opts.orderBy && opts.orderBy.table !== table.$alias) {
throw new Error(`orderBy ${opts.orderBy.table}.${opts.orderBy.column} is not on ${table.$alias}`)
}
let query = from(table)
.where(inList(rel.foreign as ColRef<unknown>, uniqueKeys(keys)))
.select(shape)
if (opts.orderBy) query = query.orderBy(opts.orderBy, opts.dir ?? 'asc')
return query.compile(opts.dialect ?? 'postgres')
}

export function nestHasMany<As extends string, P extends Record<string, unknown>, C extends Record<string, unknown>>(
rel: HasManyRel<As>,
parents: readonly P[],
children: readonly C[],
): Array<P & { [K in As]: C[] }> {
const groups = new Map<unknown, C[]>()
for (const child of children) {
const key = readKey(child, rel.foreign.column, rel.child.$alias)
if (key === null || key === undefined) continue
const bucket = groups.get(key)
if (bucket) bucket.push(child)
else groups.set(key, [child])
}
return parents.map((parent) => {
const key = readKey(parent, rel.local.column, rel.parent.$alias)
const related = key === null || key === undefined ? [] : (groups.get(key) ?? [])
return { ...parent, [rel.as]: related.slice() } as P & { [K in As]: C[] }
})
}

export function nestBelongsTo<As extends string, C extends Record<string, unknown>, P extends Record<string, unknown>>(
rel: BelongsToRel<As>,
children: readonly C[],
parents: readonly P[],
): Array<C & { [K in As]: P | null }> {
const byId = new Map<unknown, P>()
for (const parent of parents) {
const key = readKey(parent, rel.foreign.column, rel.parent.$alias)
if (key === null || key === undefined) continue
if (!byId.has(key)) byId.set(key, parent)
}
return children.map((child) => {
const key = readKey(child, rel.local.column, rel.child.$alias)
const related = key === null || key === undefined ? null : (byId.get(key) ?? null)
return { ...child, [rel.as]: related } as C & { [K in As]: P | null }
})
}

export function eagerHasMany<As extends string, P extends Record<string, unknown>, S extends SelectShape>(
session: SqlSession,
rel: HasManyRel<As>,
parents: readonly P[],
childSelect: S,
opts: RelatedQueryOpts = {},
): Array<P & { [K in As]: Array<InferRow<S>> }> {
const keys = uniqueKeys(parents.map((parent) => readKey(parent, rel.local.column, rel.parent.$alias)))
if (keys.length === 0) return nestHasMany(rel, parents, [])
const compiled = relatedQuery(rel, keys, childSelect, opts)
const children = session.all(compiled.sql, compiled.params) as Array<InferRow<S>>
return nestHasMany(rel, parents, children)
}

export function eagerBelongsTo<As extends string, C extends Record<string, unknown>, S extends SelectShape>(
session: SqlSession,
rel: BelongsToRel<As>,
children: readonly C[],
parentSelect: S,
opts: RelatedQueryOpts = {},
): Array<C & { [K in As]: InferRow<S> | null }> {
const keys = uniqueKeys(children.map((child) => readKey(child, rel.local.column, rel.child.$alias)))
if (keys.length === 0) return nestBelongsTo(rel, children, [])
const compiled = relatedQuery(rel, keys, parentSelect, opts)
const parents = session.all(compiled.sql, compiled.params) as Array<InferRow<S>>
return nestBelongsTo(rel, children, parents)
}
Loading
Loading