From 22ecc6b3f3159a726dbc683ca4a934fbe411eedc Mon Sep 17 00:00:00 2001 From: Thomas Hart Date: Tue, 1 Sep 2026 19:05:05 +0000 Subject: [PATCH 1/4] feat: add type-safe SELECT/WHERE/JOIN query builder Compile typed table definitions into parameterized SQL. Values bind through $n or ?, identifiers are quoted, and NULL / empty IN follow SQL's actual rules instead of string concatenation. --- README.md | 38 ++++-- src/index.ts | 3 +- src/query.ts | 252 +++++++++++++++++++++++++++++++++++++ src/schema.ts | 88 +++++++++++++ test/query-builder.test.ts | 154 +++++++++++++++++++++++ test/version.test.ts | 6 - 6 files changed, 526 insertions(+), 15 deletions(-) create mode 100644 src/query.ts create mode 100644 src/schema.ts create mode 100644 test/query-builder.test.ts delete mode 100644 test/version.test.ts diff --git a/README.md b/README.md index 0facd34..32f92c2 100644 --- a/README.md +++ b/README.md @@ -8,24 +8,46 @@ Most ORM tutorials stop at `findMany`. This repo goes the other way. It builds t ## Concepts demonstrated -- Relational algebra for SELECT, projection, selection, and join +- Relational algebra: projection (`SELECT`), selection (`WHERE`), and join - Prepared statements and bind parameters (SQL injection resistance) -- Identifier quoting vs value binding -- SQL three-valued logic (`= NULL` is unknown) -- Dialect-specific parameter placeholders (`$1` vs `?`) +- Identifier quoting versus value binding +- SQL three-valued logic (`= NULL` is unknown; use `IS NULL`) +- Dialect-specific placeholders (`$n` for Postgres, `?` for SQLite) +- Phantom types so column refs carry their TypeScript value type +- Immutable query values (each clause returns a new builder) +- Empty `IN ()` as a false predicate, because that list is not valid SQL ## What's implemented -- Project scaffold (TypeScript, Vitest, GitHub Actions CI) +- Type-safe SELECT/WHERE/JOIN query builder with prepared-statement binding ## Usage ```ts -import { version } from 'orm-workbench' - -console.log(version) +import { and, boolean, defineTable, eq, from, gt, integer, text } from 'orm-workbench' + +const users = defineTable('users', { + id: integer(), + email: text(), + active: boolean(), +}) +const orders = defineTable('orders', { + id: integer(), + userId: integer(), + totalCents: integer(), +}) + +const compiled = from(orders) + .innerJoin(users, eq(orders.userId, users.id)) + .where(and(eq(users.active, true), gt(orders.totalCents, 500))) + .select({ orderId: orders.id, email: users.email }) + .orderBy(orders.id, 'desc') + .limit(20) + .compile('postgres') ``` +`compiled.sql` is parameterized. `compiled.params` is `[true, 500, 20]`. + ```bash pnpm install pnpm run typecheck diff --git a/src/index.ts b/src/index.ts index 6ccd2f1..25a2b24 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1 +1,2 @@ -export const version = '0.1.0' +export * from './schema' +export * from './query' diff --git a/src/query.ts b/src/query.ts new file mode 100644 index 0000000..9f95b2a --- /dev/null +++ b/src/query.ts @@ -0,0 +1,252 @@ +import { isColRef, quoteIdent, type AnyTable, type ColRef } from './schema' + +export type Dialect = 'postgres' | 'sqlite' + +export type PreparedQuery = { + sql: string + params: unknown[] + dialect: Dialect + rowType?: TRow +} + +type CmpOp = '=' | '<>' | '>' | '>=' | '<' | '<=' + +type Operand = { kind: 'col'; col: ColRef } | { kind: 'val'; value: unknown } + +export type Predicate = + | { kind: 'cmp'; op: CmpOp; left: ColRef; right: Operand } + | { kind: 'nullcheck'; left: ColRef; negated: boolean } + | { kind: 'in'; left: ColRef; values: unknown[] } + | { kind: 'like'; left: ColRef; pattern: string } + | { kind: 'and'; parts: Predicate[] } + | { kind: 'or'; parts: Predicate[] } + | { kind: 'not'; pred: Predicate } + +function rejectNullish(value: unknown, context: string): void { + if (value === null || value === undefined) { + throw new Error(`${context}: SQL NULL needs isNull()/isNotNull(); equality with NULL is never true`) + } +} + +function operand(right: ColRef | T, context: string): Operand { + if (isColRef(right)) return { kind: 'col', col: right } + rejectNullish(right, context) + return { kind: 'val', value: right } +} + +function cmp(op: CmpOp, context: string) { + return (left: ColRef, right: ColRef | T): Predicate => ({ + kind: 'cmp', + op, + left, + right: operand(right, context), + }) +} + +export const eq = cmp('=', 'eq') +export const ne = cmp('<>', 'ne') +export const gt = cmp('>', 'gt') +export const gte = cmp('>=', 'gte') +export const lt = cmp('<', 'lt') +export const lte = cmp('<=', 'lte') + +export function isNull(left: ColRef): Predicate { + return { kind: 'nullcheck', left, negated: false } +} + +export function isNotNull(left: ColRef): Predicate { + return { kind: 'nullcheck', left, negated: true } +} + +export function inList(left: ColRef, values: readonly T[]): Predicate { + for (const value of values) rejectNullish(value, 'inList') + return { kind: 'in', left, values: [...values] } +} + +export function like(left: ColRef, pattern: string): Predicate { + rejectNullish(pattern, 'like') + return { kind: 'like', left, pattern } +} + +export function and(...parts: Predicate[]): Predicate { + return { kind: 'and', parts } +} + +export function or(...parts: Predicate[]): Predicate { + return { kind: 'or', parts } +} + +export function not(pred: Predicate): Predicate { + return { kind: 'not', pred } +} + +class Binder { + readonly params: unknown[] = [] + constructor(private readonly dialect: Dialect) {} + + push(value: unknown): string { + this.params.push(value) + if (this.dialect === 'sqlite') return '?' + return `$${this.params.length}` + } +} + +function colSql(col: ColRef): string { + return `${quoteIdent(col.table)}.${quoteIdent(col.column)}` +} + +function compileOperand(right: Operand, bind: Binder): string { + return right.kind === 'col' ? colSql(right.col) : bind.push(right.value) +} + +function compilePred(pred: Predicate, bind: Binder): string { + switch (pred.kind) { + case 'cmp': + return `${colSql(pred.left)} ${pred.op} ${compileOperand(pred.right, bind)}` + case 'nullcheck': + return `${colSql(pred.left)} IS ${pred.negated ? 'NOT NULL' : 'NULL'}` + case 'in': + // IN () is invalid SQL; empty membership is false. + if (pred.values.length === 0) return '1 = 0' + return `${colSql(pred.left)} IN (${pred.values.map((value) => bind.push(value)).join(', ')})` + case 'like': + return `${colSql(pred.left)} LIKE ${bind.push(pred.pattern)}` + case 'and': + if (pred.parts.length === 0) return '1 = 1' + return `(${pred.parts.map((part) => compilePred(part, bind)).join(' AND ')})` + case 'or': + if (pred.parts.length === 0) return '1 = 0' + return `(${pred.parts.map((part) => compilePred(part, bind)).join(' OR ')})` + case 'not': + return `(NOT ${compilePred(pred.pred, bind)})` + } +} + +type JoinKind = 'INNER' | 'LEFT' +type JoinClause = { kind: JoinKind; tableName: string; alias: string; on: Predicate } +type OrderClause = { col: ColRef; dir: 'ASC' | 'DESC' } + +type SelectShape = Record> + +export type InferRow = { + [K in keyof S]: S[K] extends ColRef ? T : never +} + +type QueryState = { + fromName: string + fromAlias: string + joins: readonly JoinClause[] + where: Predicate | undefined + select: SelectShape | undefined + orderBy: readonly OrderClause[] + limit?: number + offset?: number +} + +function aliasesOf(state: QueryState): Set { + return new Set([state.fromAlias, ...state.joins.map((join) => join.alias)]) +} + +function requireCount(n: number, label: string): number { + if (!Number.isInteger(n) || n < 0) { + throw new Error(`${label}() expects a non-negative integer, got ${String(n)}`) + } + return n +} + +export class SelectQuery { + private constructor(private readonly state: QueryState) {} + + static start(table: AnyTable): SelectQuery { + return new SelectQuery({ + fromName: table.$name, + fromAlias: table.$alias, + joins: [], + where: undefined, + select: undefined, + orderBy: [], + limit: undefined, + offset: undefined, + }) + } + + innerJoin(table: AnyTable, on: Predicate): SelectQuery { + return this.addJoin('INNER', table, on) + } + + leftJoin(table: AnyTable, on: Predicate): SelectQuery { + return this.addJoin('LEFT', table, on) + } + + where(pred: Predicate): SelectQuery { + const where = this.state.where ? and(this.state.where, pred) : pred + return new SelectQuery({ ...this.state, where }) + } + + select(shape: S): SelectQuery> { + if (this.state.select) { + throw new Error('select() already called') + } + const keys = Object.keys(shape) + if (keys.length === 0) { + throw new Error('select() needs at least one column') + } + for (const key of keys) quoteIdent(key) + return new SelectQuery({ ...this.state, select: shape }) + } + + orderBy(col: ColRef, dir: 'asc' | 'desc' = 'asc'): SelectQuery { + return new SelectQuery({ + ...this.state, + orderBy: [...this.state.orderBy, { col, dir: dir === 'desc' ? 'DESC' : 'ASC' }], + }) + } + + limit(n: number): SelectQuery { + return new SelectQuery({ ...this.state, limit: requireCount(n, 'limit') }) + } + + offset(n: number): SelectQuery { + return new SelectQuery({ ...this.state, offset: requireCount(n, 'offset') }) + } + + compile(dialect: Dialect = 'postgres'): PreparedQuery { + const shape = this.state.select + if (!shape) { + throw new Error('compile() requires select()') + } + const bind = new Binder(dialect) + const projections = Object.keys(shape).map((alias) => { + const col = shape[alias] + if (!col) throw new Error(`Missing select column ${alias}`) + return `${colSql(col)} AS ${quoteIdent(alias)}` + }) + const fromSql = `FROM ${quoteIdent(this.state.fromName)} AS ${quoteIdent(this.state.fromAlias)}` + const joinSql = this.state.joins.map((join) => { + return `${join.kind} JOIN ${quoteIdent(join.tableName)} AS ${quoteIdent(join.alias)} ON ${compilePred(join.on, bind)}` + }) + const parts = [`SELECT ${projections.join(', ')}`, fromSql, ...joinSql] + if (this.state.where) parts.push(`WHERE ${compilePred(this.state.where, bind)}`) + if (this.state.orderBy.length > 0) { + const order = this.state.orderBy.map((item) => `${colSql(item.col)} ${item.dir}`).join(', ') + parts.push(`ORDER BY ${order}`) + } + if (this.state.limit !== undefined) parts.push(`LIMIT ${bind.push(this.state.limit)}`) + if (this.state.offset !== undefined) parts.push(`OFFSET ${bind.push(this.state.offset)}`) + return { sql: parts.join('\n'), params: bind.params, dialect } + } + + private addJoin(kind: JoinKind, table: AnyTable, on: Predicate): SelectQuery { + if (aliasesOf(this.state).has(table.$alias)) { + throw new Error(`Join alias ${JSON.stringify(table.$alias)} is already in the query`) + } + return new SelectQuery({ + ...this.state, + joins: [...this.state.joins, { kind, tableName: table.$name, alias: table.$alias, on }], + }) + } +} + +export function from(table: AnyTable): SelectQuery { + return SelectQuery.start(table) +} diff --git a/src/schema.ts b/src/schema.ts new file mode 100644 index 0000000..fbd0fcf --- /dev/null +++ b/src/schema.ts @@ -0,0 +1,88 @@ +const IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/ + +export function quoteIdent(name: string): string { + if (!IDENT.test(name)) { + throw new Error(`Invalid SQL identifier: ${JSON.stringify(name)}`) + } + return `"${name}"` +} + +export type ColumnKind = 'text' | 'integer' | 'boolean' | 'numeric' | 'timestamptz' + +export type Column = { + readonly kind: ColumnKind + readonly _type: T +} + +function col(kind: ColumnKind): Column { + return { kind } as Column +} + +export const text = () => col('text') +export const integer = () => col('integer') +export const boolean = () => col('boolean') +export const numeric = () => col('numeric') +export const timestamptz = () => col('timestamptz') + +export type AnyColumn = Column + +export type ColRef = { + readonly $col: true + readonly table: string + readonly column: string + readonly _type: T +} + +export function isColRef(value: unknown): value is ColRef { + return typeof value === 'object' && value !== null && (value as { $col?: unknown }).$col === true +} + +export type ColumnMap = Record + +type ColRefs = { + readonly [K in keyof Cols]: ColRef ? T : never> +} + +export type AnyTable = { + readonly $name: string + readonly $alias: string + readonly $columns: ColumnMap +} + +export type Table = + AnyTable & { readonly $name: Name; readonly $alias: Alias; readonly $columns: Cols } & ColRefs + +function refsFor(alias: string, columns: ColumnMap): Record> { + quoteIdent(alias) + const refs: Record> = {} + for (const column of Object.keys(columns)) { + quoteIdent(column) + refs[column] = { $col: true, table: alias, column } as ColRef + } + return refs +} + +export function defineTable( + name: Name, + columns: Cols, +): Table { + quoteIdent(name) + return { + $name: name, + $alias: name, + $columns: columns, + ...refsFor(name, columns), + } as Table +} + +export function alias( + table: Table, + as: As, +): Table { + return { + $name: table.$name, + $alias: as, + $columns: table.$columns, + ...refsFor(as, table.$columns), + } as Table +} diff --git a/test/query-builder.test.ts b/test/query-builder.test.ts new file mode 100644 index 0000000..b8179cc --- /dev/null +++ b/test/query-builder.test.ts @@ -0,0 +1,154 @@ +import { expect, expectTypeOf, test } from 'vitest' +import { + alias, and, boolean, defineTable, eq, from, gt, inList, integer, isNotNull, + isNull, like, ne, not, numeric, or, quoteIdent, text, timestamptz, type InferRow, +} from '../src/index' + +const users = defineTable('users', { + id: integer(), + email: text(), + active: boolean(), +}) + +const orders = defineTable('orders', { + id: integer(), + userId: integer(), + totalCents: integer(), + status: text(), + note: text(), + paidAt: timestamptz(), + amount: numeric(), +}) + +test('binds values and never interpolates them into SQL', () => { + const poison = "open'; DROP TABLE orders;--" + const compiled = from(orders) + .where(eq(orders.status, poison)) + .select({ id: orders.id, status: orders.status }) + .compile() + + expect(compiled.sql).toBe( + 'SELECT "orders"."id" AS "id", "orders"."status" AS "status"\nFROM "orders" AS "orders"\nWHERE "orders"."status" = $1', + ) + expect(compiled.params).toEqual([poison]) + expect(compiled.sql).not.toContain('DROP TABLE') +}) + +test('inner join, filters, sort, and bound limit compile as Postgres $n', () => { + const compiled = from(orders) + .innerJoin(users, eq(orders.userId, users.id)) + .where(and(eq(users.active, true), gt(orders.totalCents, 500))) + .select({ orderId: orders.id, email: users.email, totalCents: orders.totalCents }) + .orderBy(orders.id, 'desc') + .limit(20) + .compile('postgres') + + expect(compiled.sql.split('\n')).toEqual([ + 'SELECT "orders"."id" AS "orderId", "users"."email" AS "email", "orders"."totalCents" AS "totalCents"', + 'FROM "orders" AS "orders"', + 'INNER JOIN "users" AS "users" ON "orders"."userId" = "users"."id"', + 'WHERE ("users"."active" = $1 AND "orders"."totalCents" > $2)', + 'ORDER BY "orders"."id" DESC', + 'LIMIT $3', + ]) + expect(compiled.params).toEqual([true, 500, 20]) +}) + +test('sqlite uses ? placeholders; left join, null, like, and NOT nest', () => { + const sqlite = from(users) + .where(eq(users.email, 'a@b.c')) + .select({ email: users.email }) + .offset(3) + .compile('sqlite') + expect(sqlite.sql).toContain('WHERE "users"."email" = ?') + expect(sqlite.sql).toContain('OFFSET ?') + expect(sqlite.sql).not.toMatch(/\$\d/) + expect(sqlite.params).toEqual(['a@b.c', 3]) + + const nested = from(orders) + .leftJoin(users, eq(orders.userId, users.id)) + .where(or(and(isNull(orders.paidAt), like(orders.note, '%rush%')), not(eq(orders.status, 'void')))) + .select({ id: orders.id, email: users.email }) + .compile() + expect(nested.sql).toContain('LEFT JOIN "users" AS "users"') + expect(nested.sql).toContain('"orders"."paidAt" IS NULL') + expect(nested.sql).toContain('"orders"."note" LIKE $1') + expect(nested.sql).toContain('(NOT "orders"."status" = $2)') + expect(nested.params).toEqual(['%rush%', 'void']) +}) + +test('self-join aliases keep both sides distinct', () => { + const child = alias(users, 'child') + const parent = alias(users, 'parent') + const compiled = from(child) + .innerJoin(parent, eq(child.id, parent.id)) + .select({ childEmail: child.email, parentEmail: parent.email }) + .compile() + expect(compiled.sql).toContain('FROM "users" AS "child"') + expect(compiled.sql).toContain('INNER JOIN "users" AS "parent" ON "child"."id" = "parent"."id"') +}) + +test('empty IN/OR are false, empty AND is true, IN binds duplicates', () => { + const empty = from(orders) + .where(and(inList(orders.id, []), or(), and())) + .select({ id: orders.id }) + .compile() + expect(empty.sql).toContain('WHERE (1 = 0 AND 1 = 0 AND 1 = 1)') + expect(empty.params).toEqual([]) + + const listed = from(orders) + .where(inList(orders.status, ['open', 'open', 'paid'])) + .select({ id: orders.id }) + .compile() + expect(listed.sql).toContain('IN ($1, $2, $3)') + expect(listed.params).toEqual(['open', 'open', 'paid']) +}) + +test('NULL equality is rejected; IS NULL does not allocate a bind', () => { + expect(() => eq(orders.note, null as unknown as string)).toThrow(/isNull/) + expect(() => ne(orders.note, undefined as unknown as string)).toThrow(/isNull/) + expect(() => inList(orders.id, [1, null as unknown as number])).toThrow(/inList/) + const compiled = from(orders).where(isNotNull(orders.paidAt)).select({ id: orders.id }).compile() + expect(compiled.sql).toContain('"orders"."paidAt" IS NOT NULL') + expect(compiled.params).toEqual([]) +}) + +test('rejects poisoned identifiers, empty select, duplicate aliases, and bad limits', () => { + expect(() => quoteIdent('orders;drop')).toThrow(/Invalid SQL identifier/) + expect(() => defineTable('users-x', { id: integer() })).toThrow(/Invalid SQL identifier/) + expect(() => from(orders).select({})).toThrow(/at least one column/) + expect(() => from(users).innerJoin(users, eq(users.id, users.id))).toThrow(/already in the query/) + expect(() => from(orders).select({ id: orders.id }).select({ id: orders.id })).toThrow(/already called/) + expect(() => from(orders).limit(-1)).toThrow(/non-negative/) + expect(() => from(orders).offset(1.5)).toThrow(/non-negative/) + expect(() => from(orders).compile()).toThrow(/select/) +}) + +test('builder is immutable; stacked where/orderBy; limit 0 binds', () => { + const base = from(orders).select({ id: orders.id }) + const open = base.where(eq(orders.status, 'open')) + const both = open.where(eq(orders.amount, '10.00')) + expect(base.compile().sql).not.toContain('WHERE') + expect(open.compile().params).toEqual(['open']) + expect(both.compile().sql).toContain('WHERE ("orders"."status" = $1 AND "orders"."amount" = $2)') + expect(both.compile().params).toEqual(['open', '10.00']) + + const ordered = from(orders) + .select({ id: orders.id }) + .orderBy(orders.status) + .orderBy(orders.id, 'desc') + .limit(0) + .compile() + expect(ordered.sql).toContain('ORDER BY "orders"."status" ASC, "orders"."id" DESC') + expect(ordered.params).toEqual([0]) +}) + +test('select shape infers row field types from column refs', () => { + const query = from(orders).select({ orderId: orders.id, status: orders.status }) + type Row = InferRow<{ orderId: typeof orders.id; status: typeof orders.status }> + expectTypeOf().toEqualTypeOf<{ orderId: number; status: string }>() + expectTypeOf(query.compile().sql).toEqualTypeOf() + eq(orders.id, 1) + // @ts-expect-error integer columns reject string literals + eq(orders.id, '1') +}) diff --git a/test/version.test.ts b/test/version.test.ts deleted file mode 100644 index 3ccf0ce..0000000 --- a/test/version.test.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { expect, test } from 'vitest' -import { version } from '../src/index' - -test('exports a package version', () => { - expect(version).toBe('0.1.0') -}) From 68e52933bb542796472a10799be3c38a8abb42d7 Mon Sep 17 00:00:00 2001 From: Thomas Hart Date: Tue, 1 Sep 2026 19:17:13 +0000 Subject: [PATCH 2/4] fix: emit LIMIT -1 before sqlite OFFSET without limit SQLite rejects a bare OFFSET clause. compile('sqlite') now inserts LIMIT -1 when offset is set and limit is not, and the sqlite tests prepare the statement so a syntax error fails. Postgres still allows OFFSET alone. NOT predicates wrap as (NOT (inner)). --- src/query.ts | 10 ++++-- test/query-builder.test.ts | 63 +++++++++++++++++++++++++++++++++++--- 2 files changed, 66 insertions(+), 7 deletions(-) diff --git a/src/query.ts b/src/query.ts index 9f95b2a..1684480 100644 --- a/src/query.ts +++ b/src/query.ts @@ -6,7 +6,6 @@ export type PreparedQuery = { sql: string params: unknown[] dialect: Dialect - rowType?: TRow } type CmpOp = '=' | '<>' | '>' | '>=' | '<' | '<=' @@ -118,7 +117,7 @@ function compilePred(pred: Predicate, bind: Binder): string { if (pred.parts.length === 0) return '1 = 0' return `(${pred.parts.map((part) => compilePred(part, bind)).join(' OR ')})` case 'not': - return `(NOT ${compilePred(pred.pred, bind)})` + return `(NOT (${compilePred(pred.pred, bind)}))` } } @@ -231,7 +230,12 @@ export class SelectQuery { const order = this.state.orderBy.map((item) => `${colSql(item.col)} ${item.dir}`).join(', ') parts.push(`ORDER BY ${order}`) } - if (this.state.limit !== undefined) parts.push(`LIMIT ${bind.push(this.state.limit)}`) + if (this.state.limit !== undefined) { + parts.push(`LIMIT ${bind.push(this.state.limit)}`) + } else if (dialect === 'sqlite' && this.state.offset !== undefined) { + // SQLite is LIMIT expr [OFFSET expr]; -1 is unbounded so OFFSET can stand alone. + parts.push('LIMIT -1') + } if (this.state.offset !== undefined) parts.push(`OFFSET ${bind.push(this.state.offset)}`) return { sql: parts.join('\n'), params: bind.params, dialect } } diff --git a/test/query-builder.test.ts b/test/query-builder.test.ts index b8179cc..7654dd7 100644 --- a/test/query-builder.test.ts +++ b/test/query-builder.test.ts @@ -20,6 +20,30 @@ const orders = defineTable('orders', { amount: numeric(), }) +type SqliteDb = { + exec: (sql: string) => void + prepare: (sql: string) => { all: (...params: unknown[]) => unknown } + close: () => void +} + +function execSqlite(sql: string, params: unknown[], schema: string): void { + const loaded = ( + globalThis as { + process?: { getBuiltinModule?: (id: string) => { DatabaseSync: new (path: string) => SqliteDb } } + } + ).process?.getBuiltinModule?.('node:sqlite') + if (!loaded) { + throw new Error('node:sqlite is not available in this runtime') + } + const db = new loaded.DatabaseSync(':memory:') + try { + db.exec(schema) + db.prepare(sql).all(...params) + } finally { + db.close() + } +} + test('binds values and never interpolates them into SQL', () => { const poison = "open'; DROP TABLE orders;--" const compiled = from(orders) @@ -54,17 +78,48 @@ test('inner join, filters, sort, and bound limit compile as Postgres $n', () => expect(compiled.params).toEqual([true, 500, 20]) }) -test('sqlite uses ? placeholders; left join, null, like, and NOT nest', () => { +test('sqlite uses ? placeholders and LIMIT -1 before a lone OFFSET', () => { const sqlite = from(users) .where(eq(users.email, 'a@b.c')) .select({ email: users.email }) .offset(3) .compile('sqlite') - expect(sqlite.sql).toContain('WHERE "users"."email" = ?') - expect(sqlite.sql).toContain('OFFSET ?') + expect(sqlite.sql.split('\n')).toEqual([ + 'SELECT "users"."email" AS "email"', + 'FROM "users" AS "users"', + 'WHERE "users"."email" = ?', + 'LIMIT -1', + 'OFFSET ?', + ]) expect(sqlite.sql).not.toMatch(/\$\d/) expect(sqlite.params).toEqual(['a@b.c', 3]) + const withLimit = from(users) + .select({ email: users.email }) + .limit(10) + .offset(3) + .compile('sqlite') + expect(withLimit.sql).toContain('LIMIT ?') + expect(withLimit.sql).toContain('OFFSET ?') + expect(withLimit.sql).not.toContain('LIMIT -1') + expect(withLimit.params).toEqual([10, 3]) + + const usersSchema = 'CREATE TABLE "users" ("id" INTEGER, "email" TEXT, "active" INTEGER)' + execSqlite(sqlite.sql, sqlite.params, usersSchema) + execSqlite(withLimit.sql, withLimit.params, usersSchema) + const bareOffset = sqlite.sql.replace('\nLIMIT -1', '') + expect(() => execSqlite(bareOffset, sqlite.params, usersSchema)).toThrow(/syntax error/i) +}) + +test('postgres allows OFFSET without LIMIT; nested boolean, left join, null, like, NOT', () => { + const offsetOnly = from(users) + .select({ email: users.email }) + .offset(3) + .compile('postgres') + expect(offsetOnly.sql).toContain('OFFSET $1') + expect(offsetOnly.sql).not.toContain('LIMIT') + expect(offsetOnly.params).toEqual([3]) + const nested = from(orders) .leftJoin(users, eq(orders.userId, users.id)) .where(or(and(isNull(orders.paidAt), like(orders.note, '%rush%')), not(eq(orders.status, 'void')))) @@ -73,7 +128,7 @@ test('sqlite uses ? placeholders; left join, null, like, and NOT nest', () => { expect(nested.sql).toContain('LEFT JOIN "users" AS "users"') expect(nested.sql).toContain('"orders"."paidAt" IS NULL') expect(nested.sql).toContain('"orders"."note" LIKE $1') - expect(nested.sql).toContain('(NOT "orders"."status" = $2)') + expect(nested.sql).toContain('(NOT ("orders"."status" = $2))') expect(nested.params).toEqual(['%rush%', 'void']) }) From 17a32284ff74f5f2153b24dc5f425311427b1f81 Mon Sep 17 00:00:00 2001 From: Thomas Hart Date: Tue, 1 Sep 2026 19:22:53 +0000 Subject: [PATCH 3/4] fix: run CI on Node 22 for node:sqlite GitHub Actions was on Node 20, but the sqlite prepare test loads node:sqlite, which landed in 22.5. Pin engines and CI to 22 so that proof actually runs on the public workflow. --- .github/workflows/ci.yml | 2 +- package.json | 3 +++ test/query-builder.test.ts | 26 ++++++++++++++++++++++++++ 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 823da0c..b0d5334 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ jobs: version: 9 - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm run typecheck diff --git a/package.json b/package.json index d0358d6..3e60245 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,9 @@ "description": "A deep dive into ORMs and hard relational design: build a small type-safe ORM from scratch (query builder, migrations, relations, unit-of-work) AND model a genuinely complex schema (multi-tenant, temporal/bitemporal, hierarchies, money, soft-delete) showing where the ORM helps and where you drop to hand-tuned SQL. Coordinates ORM internals with real database-design decisions.", "type": "module", "license": "MIT", + "engines": { + "node": ">=22" + }, "scripts": { "typecheck": "tsc --noEmit", "test": "vitest run", diff --git a/test/query-builder.test.ts b/test/query-builder.test.ts index 7654dd7..00673a7 100644 --- a/test/query-builder.test.ts +++ b/test/query-builder.test.ts @@ -44,6 +44,32 @@ function execSqlite(sql: string, params: unknown[], schema: string): void { } } +test('CI and package engines pin Node 22 so node:sqlite is a hard require', () => { + const proc = ( + globalThis as { + process?: { + versions?: { node?: string } + getBuiltinModule?: (id: string) => { + readFileSync: (path: string, encoding: string) => string + } + } + } + ).process + const major = Number((proc?.versions?.node ?? '0').split('.')[0]) + expect(major).toBeGreaterThanOrEqual(22) + const fs = proc?.getBuiltinModule?.('node:fs') + if (!fs) { + throw new Error('node:fs is not available in this runtime') + } + const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')) as { + engines?: { node?: string } + } + expect(pkg.engines?.node).toBe('>=22') + const ci = fs.readFileSync('.github/workflows/ci.yml', 'utf8') + expect(ci).toMatch(/node-version:\s*22\b/) + expect(ci).not.toMatch(/node-version:\s*20\b/) +}) + test('binds values and never interpolates them into SQL', () => { const poison = "open'; DROP TABLE orders;--" const compiled = from(orders) From caa2a595efdd16abfb62338bfac23201f913eae3 Mon Sep 17 00:00:00 2001 From: Thomas Hart Date: Tue, 1 Sep 2026 19:31:20 +0000 Subject: [PATCH 4/4] fix: encode sqlite boolean and Date bind params node:sqlite rejects JS booleans and binds Date as NULL. Convert true/false to 1/0 and Date to ISO strings in Binder so eq, inList, like, and cmp share the conversion. Postgres still receives the original JS values. --- src/query.ts | 8 ++++++- test/query-builder.test.ts | 47 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/src/query.ts b/src/query.ts index 1684480..05e2ec3 100644 --- a/src/query.ts +++ b/src/query.ts @@ -79,12 +79,18 @@ export function not(pred: Predicate): Predicate { return { kind: 'not', pred } } +function encodeSqlite(value: unknown): unknown { + if (typeof value === 'boolean') return value ? 1 : 0 + if (value instanceof Date) return value.toISOString() + return value +} + class Binder { readonly params: unknown[] = [] constructor(private readonly dialect: Dialect) {} push(value: unknown): string { - this.params.push(value) + this.params.push(this.dialect === 'sqlite' ? encodeSqlite(value) : value) if (this.dialect === 'sqlite') return '?' return `$${this.params.length}` } diff --git a/test/query-builder.test.ts b/test/query-builder.test.ts index 00673a7..f50af87 100644 --- a/test/query-builder.test.ts +++ b/test/query-builder.test.ts @@ -104,6 +104,53 @@ test('inner join, filters, sort, and bound limit compile as Postgres $n', () => expect(compiled.params).toEqual([true, 500, 20]) }) +test('sqlite encodes boolean and Date binds; postgres keeps JS values', () => { + const usersSchema = 'CREATE TABLE "users" ("id" INTEGER, "email" TEXT, "active" INTEGER)' + const activeTrue = from(users) + .where(eq(users.active, true)) + .select({ id: users.id }) + .compile('sqlite') + expect(activeTrue.params).toEqual([1]) + execSqlite(activeTrue.sql, activeTrue.params, usersSchema) + + const activeFalse = from(users) + .where(eq(users.active, false)) + .select({ id: users.id }) + .compile('sqlite') + expect(activeFalse.params).toEqual([0]) + execSqlite(activeFalse.sql, activeFalse.params, usersSchema) + + const listed = from(users) + .where(inList(users.active, [true, false])) + .select({ id: users.id }) + .compile('sqlite') + expect(listed.params).toEqual([1, 0]) + execSqlite(listed.sql, listed.params, usersSchema) + + const pgActive = from(users) + .where(eq(users.active, true)) + .select({ id: users.id }) + .compile('postgres') + expect(pgActive.params).toEqual([true]) + + const paidAt = new Date('2026-09-01T18:55:04.926Z') + const ordersSchema = 'CREATE TABLE "orders" ("id" INTEGER, "paidAt" TEXT)' + const paid = from(orders) + .where(eq(orders.paidAt, paidAt)) + .select({ id: orders.id }) + .compile('sqlite') + expect(paid.params).toEqual([paidAt.toISOString()]) + expect(typeof paid.params[0]).toBe('string') + execSqlite(paid.sql, paid.params, ordersSchema) + + const listedDates = from(orders) + .where(inList(orders.paidAt, [paidAt])) + .select({ id: orders.id }) + .compile('sqlite') + expect(listedDates.params).toEqual([paidAt.toISOString()]) + execSqlite(listedDates.sql, listedDates.params, ordersSchema) +}) + test('sqlite uses ? placeholders and LIMIT -1 before a lone OFFSET', () => { const sqlite = from(users) .where(eq(users.email, 'a@b.c'))