From 99f9e83445a25d8566a37bbecbca9c2568ef86d6 Mon Sep 17 00:00:00 2001 From: Vinicius Dacal Date: Sat, 4 Apr 2026 19:55:04 -0300 Subject: [PATCH 1/6] feat(db): add DbExpr type, d.expr/increment/decrement, widen update types #1743 Introduce column-relative SQL expressions for update operations. - DbExpr interface and isDbExpr() type guard in sql/expr.ts - d.expr(), d.increment(), d.decrement() on the d namespace - Widen $update and $update_input types to accept DbExpr - Re-export sql from main @vertz/db entry for single-import DX - Export DbExpr type and isDbExpr from both @vertz/db and @vertz/db/sql Co-Authored-By: Claude Opus 4.6 --- packages/db/src/d.ts | 12 +++ packages/db/src/index.ts | 6 ++ packages/db/src/schema/table.ts | 5 +- packages/db/src/sql/__tests__/expr.test.ts | 100 +++++++++++++++++++++ packages/db/src/sql/expr.ts | 49 ++++++++++ packages/db/src/sql/index.ts | 2 + 6 files changed, 172 insertions(+), 2 deletions(-) create mode 100644 packages/db/src/sql/__tests__/expr.test.ts create mode 100644 packages/db/src/sql/expr.ts diff --git a/packages/db/src/d.ts b/packages/db/src/d.ts index 3195e1ccb..19ccf74af 100644 --- a/packages/db/src/d.ts +++ b/packages/db/src/d.ts @@ -11,6 +11,9 @@ import type { VarcharMeta, } from './schema/column'; import { createColumn, createSerialColumn } from './schema/column'; +import type { DbExpr } from './sql/expr'; +import type { SqlFragment } from './sql/tagged'; +import { sql } from './sql/tagged'; import type { ModelDef, ValidateOneRelationFKs } from './schema/model'; import { createModel } from './schema/model'; import type { SchemaLike } from './schema/model-schemas'; @@ -88,6 +91,12 @@ export const d: { ): RelationDef; many>(target: () => TTarget): ManyRelationDef; }; + /** Column-relative SQL expression for update operations. */ + expr(build: (col: SqlFragment) => SqlFragment): DbExpr; + /** Atomic increment: `SET col = col + value`. */ + increment(value: number): DbExpr; + /** Atomic decrement: `SET col = col - value`. */ + decrement(value: number): DbExpr; // eslint-disable-next-line @typescript-eslint/no-empty-object-type -- {} represents an empty relations record — the correct default for models without relations model>(table: TTable): ModelDef; model, TRelations extends Record>( @@ -162,6 +171,9 @@ export const d: { enumValues: values, }); }, + expr: (build) => ({ _tag: 'DbExpr' as const, build }), + increment: (n) => ({ _tag: 'DbExpr' as const, build: (col: SqlFragment) => sql`${col} + ${n}` }), + decrement: (n) => ({ _tag: 'DbExpr' as const, build: (col: SqlFragment) => sql`${col} - ${n}` }), table: createTable, index: (columns: string | string[], options?: IndexOptions) => createIndex(columns, options), ref: { diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index f25802da3..e9fc8ae4c 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -58,6 +58,12 @@ export { computeTenantGraph, createDb } from './client'; export type { createPostgresDriver, PostgresDriver } from './client/postgres-driver'; // Schema builder export { d } from './d'; +// Update expressions +export type { DbExpr } from './sql/expr'; +export { isDbExpr } from './sql/expr'; +// SQL tagged template (re-exported for d.expr() convenience) +export { sql } from './sql/tagged'; +export type { SqlFragment } from './sql/tagged'; // Diagnostic export type { DiagnosticResult } from './diagnostic/index'; export { diagnoseError, explainError, formatDiagnostic } from './diagnostic/index'; diff --git a/packages/db/src/schema/table.ts b/packages/db/src/schema/table.ts index 344eabfbd..6b3fc2202 100644 --- a/packages/db/src/schema/table.ts +++ b/packages/db/src/schema/table.ts @@ -1,5 +1,6 @@ import type { ColumnBuilder, ColumnMetadata, InferColumnType } from './column'; import type { RelationDef } from './relation'; +import type { DbExpr } from '../sql/expr'; // --------------------------------------------------------------------------- // Index Definition @@ -119,7 +120,7 @@ type Insert = { * Primary key excluded (you don't update a PK). */ type Update = { - [K in ColumnKeysWhereNot]?: InferColumnType; + [K in ColumnKeysWhereNot]?: InferColumnType | DbExpr; }; /** @@ -167,7 +168,7 @@ type ApiCreateInput = { type ApiUpdateInput = { [K in ColumnKeysWhereNot & ColumnKeysWhereNot & - string]?: InferColumnType; + string]?: InferColumnType | DbExpr; }; // --------------------------------------------------------------------------- diff --git a/packages/db/src/sql/__tests__/expr.test.ts b/packages/db/src/sql/__tests__/expr.test.ts new file mode 100644 index 000000000..b3409dc20 --- /dev/null +++ b/packages/db/src/sql/__tests__/expr.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from 'bun:test'; +import { d } from '../../d'; +import { isDbExpr } from '../expr'; +import { sql } from '../tagged'; + +describe('DbExpr', () => { + describe('isDbExpr', () => { + it('returns true for a DbExpr object', () => { + const expr = d.expr((col) => sql`${col} + ${1}`); + expect(isDbExpr(expr)).toBe(true); + }); + + it('returns false for a plain object', () => { + expect(isDbExpr({ increment: 1 })).toBe(false); + }); + + it('returns false for null', () => { + expect(isDbExpr(null)).toBe(false); + }); + + it('returns false for a string', () => { + expect(isDbExpr('now')).toBe(false); + }); + + it('returns false for a number', () => { + expect(isDbExpr(42)).toBe(false); + }); + }); + + describe('d.expr()', () => { + it('creates a DbExpr with the given build function', () => { + const expr = d.expr((col) => sql`${col} + ${1}`); + expect(expr._tag).toBe('DbExpr'); + expect(typeof expr.build).toBe('function'); + }); + + it('build function composes column reference with expression', () => { + const expr = d.expr((col) => sql`${col} + ${1}`); + const colRef = sql.raw('"click_count"'); + const result = expr.build(colRef); + expect(result.sql).toBe('"click_count" + $1'); + expect(result.params).toEqual([1]); + }); + + it('build function handles SQL functions', () => { + const expr = d.expr((col) => sql`UPPER(${col})`); + const colRef = sql.raw('"slug"'); + const result = expr.build(colRef); + expect(result.sql).toBe('UPPER("slug")'); + expect(result.params).toEqual([]); + }); + + it('build function handles complex expressions with multiple params', () => { + const penalty = 5; + const expr = d.expr((col) => sql`GREATEST(${col} - ${penalty}, ${0})`); + const colRef = sql.raw('"score"'); + const result = expr.build(colRef); + expect(result.sql).toBe('GREATEST("score" - $1, $2)'); + expect(result.params).toEqual([5, 0]); + }); + }); + + describe('d.increment()', () => { + it('creates a DbExpr that adds to the column', () => { + const expr = d.increment(1); + expect(isDbExpr(expr)).toBe(true); + const colRef = sql.raw('"click_count"'); + const result = expr.build(colRef); + expect(result.sql).toBe('"click_count" + $1'); + expect(result.params).toEqual([1]); + }); + + it('supports non-1 values', () => { + const expr = d.increment(5); + const colRef = sql.raw('"count"'); + const result = expr.build(colRef); + expect(result.sql).toBe('"count" + $1'); + expect(result.params).toEqual([5]); + }); + }); + + describe('d.decrement()', () => { + it('creates a DbExpr that subtracts from the column', () => { + const expr = d.decrement(1); + expect(isDbExpr(expr)).toBe(true); + const colRef = sql.raw('"stock"'); + const result = expr.build(colRef); + expect(result.sql).toBe('"stock" - $1'); + expect(result.params).toEqual([1]); + }); + + it('supports non-1 values', () => { + const expr = d.decrement(3); + const colRef = sql.raw('"balance"'); + const result = expr.build(colRef); + expect(result.sql).toBe('"balance" - $1'); + expect(result.params).toEqual([3]); + }); + }); +}); diff --git a/packages/db/src/sql/expr.ts b/packages/db/src/sql/expr.ts new file mode 100644 index 000000000..6d472d7c4 --- /dev/null +++ b/packages/db/src/sql/expr.ts @@ -0,0 +1,49 @@ +/** + * Update expressions for column-relative SQL operations. + * + * `DbExpr` represents a SQL expression that references the current column value. + * Used in `update()`, `updateMany()`, and `upsert()` to express atomic operations + * like increment, decrement, or arbitrary SQL functions without dropping to raw SQL. + * + * @example + * ```ts + * import { d, sql } from '@vertz/db'; + * + * // Atomic increment + * db.urls.update({ + * where: { id }, + * data: { clickCount: d.increment(1) }, + * }); + * + * // Arbitrary SQL expression + * db.urls.update({ + * where: { id }, + * data: { slug: d.expr((col) => sql`UPPER(${col})`) }, + * }); + * ``` + */ + +import type { SqlFragment } from './tagged'; + +/** + * A column-relative SQL expression. + * + * The `build` callback receives a `SqlFragment` representing the quoted column + * reference and must return a `SqlFragment` for the full SET expression value. + */ +export interface DbExpr { + readonly _tag: 'DbExpr'; + readonly build: (columnRef: SqlFragment) => SqlFragment; +} + +/** + * Type guard for `DbExpr` values. + */ +export function isDbExpr(value: unknown): value is DbExpr { + return ( + typeof value === 'object' && + value !== null && + '_tag' in value && + (value as DbExpr)._tag === 'DbExpr' + ); +} diff --git a/packages/db/src/sql/index.ts b/packages/db/src/sql/index.ts index 84197afd0..6590c97f1 100644 --- a/packages/db/src/sql/index.ts +++ b/packages/db/src/sql/index.ts @@ -1,4 +1,6 @@ export { camelToSnake, snakeToCamel } from './casing'; +export type { DbExpr } from './expr'; +export { isDbExpr } from './expr'; export type { DeleteOptions, DeleteResult } from './delete'; export { buildDelete } from './delete'; export type { InsertOptions, InsertResult, OnConflictOptions } from './insert'; From 55d410b01ffcdbe758458dc0478d366184c4da30 Mon Sep 17 00:00:00 2001 From: Vinicius Dacal Date: Sat, 4 Apr 2026 20:00:22 -0300 Subject: [PATCH 2/6] feat(db): add DbExpr support to buildUpdate, buildInsert, and CRUD layer #1743 - Export renumberParamsWithDialect() from tagged.ts for dialect-aware fragment composition (supports both $N and ? param styles) - buildUpdate: detect DbExpr values in SET clause, compose SqlFragment with correct param renumbering - buildInsert: detect DbExpr values in ON CONFLICT DO UPDATE SET clause for upsert expression support - Fix autoUpdate injection to not overwrite user-provided values/expressions in update(), updateMany(), and upsert() Co-Authored-By: Claude Opus 4.6 --- packages/db/src/query/crud.ts | 22 +++-- packages/db/src/sql/__tests__/insert.test.ts | 55 +++++++++++++ packages/db/src/sql/__tests__/update.test.ts | 85 ++++++++++++++++++++ packages/db/src/sql/insert.ts | 23 +++++- packages/db/src/sql/tagged.ts | 19 +++++ packages/db/src/sql/update.ts | 11 ++- 6 files changed, 204 insertions(+), 11 deletions(-) diff --git a/packages/db/src/query/crud.ts b/packages/db/src/query/crud.ts index 59c4418a0..4cdacd51a 100644 --- a/packages/db/src/query/crud.ts +++ b/packages/db/src/query/crud.ts @@ -374,12 +374,14 @@ export async function update( Object.entries(options.data).filter(([key]) => !readOnlyCols.includes(key)), ); - // Auto-set autoUpdate columns to NOW(). The 'now' sentinel value is consumed - // by buildUpdate: when a key appears in both `data` (with value 'now') and - // `nowColumns`, the SQL generator emits `SET col = NOW()` instead of a - // parameterized value. This matches the existing timestamp default convention. + // Auto-set autoUpdate columns to NOW() unless the user already provided a + // value (including DbExpr). The 'now' sentinel is consumed by buildUpdate: + // when a key appears in both `data` (with value 'now') and `nowColumns`, the + // SQL generator emits `SET col = NOW()`. for (const col of autoUpdateCols) { - filteredData[col] = 'now'; + if (!(col in filteredData)) { + filteredData[col] = 'now'; + } } const allNowColumns = [...new Set([...nowColumns, ...autoUpdateCols])]; @@ -426,9 +428,11 @@ export async function updateMany( Object.entries(options.data).filter(([key]) => !readOnlyCols.includes(key)), ); - // Auto-set autoUpdate columns to NOW() (sentinel value consumed by buildUpdate) + // Auto-set autoUpdate columns to NOW() unless user provided a value for (const col of autoUpdateCols) { - filteredData[col] = 'now'; + if (!(col in filteredData)) { + filteredData[col] = 'now'; + } } const allNowColumns = [...new Set([...nowColumns, ...autoUpdateCols])]; @@ -494,7 +498,9 @@ export async function upsert( Object.entries(options.update).filter(([key]) => !readOnlyCols.includes(key)), ); for (const col of autoUpdateCols) { - filteredUpdate[col] = 'now'; + if (!(col in filteredUpdate)) { + filteredUpdate[col] = 'now'; + } } const allNowColumns = [...new Set([...nowColumns, ...autoUpdateCols])]; diff --git a/packages/db/src/sql/__tests__/insert.test.ts b/packages/db/src/sql/__tests__/insert.test.ts index c845be487..770dcc996 100644 --- a/packages/db/src/sql/__tests__/insert.test.ts +++ b/packages/db/src/sql/__tests__/insert.test.ts @@ -1,4 +1,6 @@ import { describe, expect, it } from 'bun:test'; +import { d } from '../../d'; +import { sql } from '../tagged'; import { buildInsert } from '../insert'; describe('buildInsert', () => { @@ -202,6 +204,59 @@ describe('buildInsert', () => { }); }); + describe('ON CONFLICT with DbExpr updateValues', () => { + it('handles d.increment() in ON CONFLICT DO UPDATE SET', () => { + const result = buildInsert({ + table: 'urls', + data: { slug: 'test', target: 'https://example.com', clickCount: 1 }, + onConflict: { + columns: ['slug'], + action: 'update', + updateColumns: ['clickCount'], + updateValues: { clickCount: d.increment(1) }, + }, + }); + expect(result.sql).toBe( + 'INSERT INTO "urls" ("slug", "target", "click_count") VALUES ($1, $2, $3) ON CONFLICT ("slug") DO UPDATE SET "click_count" = "click_count" + $4', + ); + expect(result.params).toEqual(['test', 'https://example.com', 1, 1]); + }); + + it('handles d.expr() in ON CONFLICT DO UPDATE SET', () => { + const result = buildInsert({ + table: 'urls', + data: { slug: 'test', target: 'https://example.com' }, + onConflict: { + columns: ['slug'], + action: 'update', + updateColumns: ['slug'], + updateValues: { slug: d.expr((col) => sql`LOWER(${col})`) }, + }, + }); + expect(result.sql).toBe( + 'INSERT INTO "urls" ("slug", "target") VALUES ($1, $2) ON CONFLICT ("slug") DO UPDATE SET "slug" = LOWER("slug")', + ); + expect(result.params).toEqual(['test', 'https://example.com']); + }); + + it('mixes expressions with direct values in ON CONFLICT', () => { + const result = buildInsert({ + table: 'urls', + data: { slug: 'test', target: 'https://example.com', clickCount: 0 }, + onConflict: { + columns: ['slug'], + action: 'update', + updateColumns: ['clickCount', 'target'], + updateValues: { clickCount: d.increment(1), target: 'https://new.com' }, + }, + }); + expect(result.sql).toBe( + 'INSERT INTO "urls" ("slug", "target", "click_count") VALUES ($1, $2, $3) ON CONFLICT ("slug") DO UPDATE SET "click_count" = "click_count" + $4, "target" = $5', + ); + expect(result.params).toEqual(['test', 'https://example.com', 0, 1, 'https://new.com']); + }); + }); + describe('default("now") sentinel handling', () => { it('converts "now" sentinel to NOW() for timestamp columns', () => { const result = buildInsert({ diff --git a/packages/db/src/sql/__tests__/update.test.ts b/packages/db/src/sql/__tests__/update.test.ts index 295e38145..2810907c8 100644 --- a/packages/db/src/sql/__tests__/update.test.ts +++ b/packages/db/src/sql/__tests__/update.test.ts @@ -1,4 +1,6 @@ import { describe, expect, it } from 'bun:test'; +import { d } from '../../d'; +import { sql } from '../tagged'; import { buildUpdate } from '../update'; describe('buildUpdate', () => { @@ -119,4 +121,87 @@ describe('buildUpdate', () => { expect(result.params).toEqual([false]); }); }); + + describe('DbExpr support', () => { + it('handles d.increment() in SET clause', () => { + const result = buildUpdate({ + table: 'urls', + data: { clickCount: d.increment(1) }, + where: { id: 'u1' }, + }); + expect(result.sql).toBe( + 'UPDATE "urls" SET "click_count" = "click_count" + $1 WHERE "id" = $2', + ); + expect(result.params).toEqual([1, 'u1']); + }); + + it('handles d.decrement() in SET clause', () => { + const result = buildUpdate({ + table: 'products', + data: { stock: d.decrement(3) }, + where: { id: 'p1' }, + }); + expect(result.sql).toBe( + 'UPDATE "products" SET "stock" = "stock" - $1 WHERE "id" = $2', + ); + expect(result.params).toEqual([3, 'p1']); + }); + + it('handles d.expr() with SQL function', () => { + const result = buildUpdate({ + table: 'urls', + data: { slug: d.expr((col) => sql`UPPER(${col})`) }, + where: { id: 'u1' }, + }); + expect(result.sql).toBe('UPDATE "urls" SET "slug" = UPPER("slug") WHERE "id" = $1'); + expect(result.params).toEqual(['u1']); + }); + + it('handles d.expr() with multiple params', () => { + const result = buildUpdate({ + table: 'scores', + data: { score: d.expr((col) => sql`GREATEST(${col} - ${5}, ${0})`) }, + where: { id: 's1' }, + }); + expect(result.sql).toBe( + 'UPDATE "scores" SET "score" = GREATEST("score" - $1, $2) WHERE "id" = $3', + ); + expect(result.params).toEqual([5, 0, 's1']); + }); + + it('mixes expressions with direct values', () => { + const result = buildUpdate({ + table: 'urls', + data: { clickCount: d.increment(1), target: 'https://new.com' }, + where: { id: 'u1' }, + }); + expect(result.sql).toBe( + 'UPDATE "urls" SET "click_count" = "click_count" + $1, "target" = $2 WHERE "id" = $3', + ); + expect(result.params).toEqual([1, 'https://new.com', 'u1']); + }); + + it('expression takes precedence over now sentinel', () => { + const result = buildUpdate({ + table: 'events', + data: { updatedAt: d.expr((col) => sql`${col} + INTERVAL '1 day'`) }, + where: { id: 'e1' }, + nowColumns: ['updatedAt'], + }); + expect(result.sql).toBe( + `UPDATE "events" SET "updated_at" = "updated_at" + INTERVAL '1 day' WHERE "id" = $1`, + ); + expect(result.params).toEqual(['e1']); + }); + + it('handles expression with no column reference (constant expression)', () => { + const result = buildUpdate({ + table: 'users', + data: { score: d.expr(() => sql`${0}`) }, + where: { id: 'u1' }, + }); + expect(result.sql).toBe('UPDATE "users" SET "score" = $1 WHERE "id" = $2'); + expect(result.params).toEqual([0, 'u1']); + }); + }); }); diff --git a/packages/db/src/sql/insert.ts b/packages/db/src/sql/insert.ts index a220e1bb8..2e78fddd7 100644 --- a/packages/db/src/sql/insert.ts +++ b/packages/db/src/sql/insert.ts @@ -11,6 +11,9 @@ import { type Dialect, defaultPostgresDialect } from '../dialect'; import { camelToSnake } from './casing'; +import { isDbExpr } from './expr'; +import type { SqlFragment } from './tagged'; +import { renumberParamsWithDialect } from './tagged'; export interface OnConflictOptions { readonly columns: readonly string[]; @@ -93,12 +96,28 @@ export function buildInsert( sql += ` ON CONFLICT (${conflictCols}) DO NOTHING`; } else if (options.onConflict.action === 'update' && options.onConflict.updateColumns) { if (options.onConflict.updateValues) { - // Explicit update values: parameterize each value + // Explicit update values: parameterize each value (or compose DbExpr) const updateVals = options.onConflict.updateValues; const setClauses = options.onConflict.updateColumns .map((c) => { const snakeCol = camelToSnake(c); - allParams.push(updateVals[c]); + const val = updateVals[c]; + if (isDbExpr(val)) { + const colRef: SqlFragment = { + _tag: 'SqlFragment', + sql: `"${snakeCol}"`, + params: [], + }; + const fragment = val.build(colRef); + const renumbered = renumberParamsWithDialect( + fragment.sql, + allParams.length, + dialect, + ); + allParams.push(...fragment.params); + return `"${snakeCol}" = ${renumbered}`; + } + allParams.push(val); return `"${snakeCol}" = ${dialect.param(allParams.length)}`; }) .join(', '); diff --git a/packages/db/src/sql/tagged.ts b/packages/db/src/sql/tagged.ts index 258c3aa18..16feffa23 100644 --- a/packages/db/src/sql/tagged.ts +++ b/packages/db/src/sql/tagged.ts @@ -49,6 +49,25 @@ function renumberParams(sqlStr: string, offset: number): string { }); } +/** + * Re-number parameter placeholders in a SQL fragment string using dialect-specific + * formatting. Replaces `$1, $2, ...` with `dialect.param(offset + 1)`, etc. + * + * Used by SQL builders to inline `SqlFragment` expressions from `DbExpr.build()` + * with correct parameter numbering for the target dialect. + */ +export function renumberParamsWithDialect( + sqlStr: string, + offset: number, + dialect: { param(index: number): string }, +): string { + let counter = 0; + return sqlStr.replace(/\$(\d+)/g, () => { + counter++; + return dialect.param(offset + counter); + }); +} + /** * SQL tagged template literal. * diff --git a/packages/db/src/sql/update.ts b/packages/db/src/sql/update.ts index 7981a9684..4fa3b8e62 100644 --- a/packages/db/src/sql/update.ts +++ b/packages/db/src/sql/update.ts @@ -11,6 +11,9 @@ import { type Dialect, defaultPostgresDialect } from '../dialect'; import { camelToSnake } from './casing'; +import { isDbExpr } from './expr'; +import type { SqlFragment } from './tagged'; +import { renumberParamsWithDialect } from './tagged'; import { buildWhere } from './where'; export interface UpdateOptions { @@ -56,7 +59,13 @@ export function buildUpdate( for (const key of keys) { const snakeCol = camelToSnake(key); const value = options.data[key]; - if (nowSet.has(key) && value === 'now') { + if (isDbExpr(value)) { + const colRef: SqlFragment = { _tag: 'SqlFragment', sql: `"${snakeCol}"`, params: [] }; + const fragment = value.build(colRef); + const renumbered = renumberParamsWithDialect(fragment.sql, allParams.length, dialect); + setClauses.push(`"${snakeCol}" = ${renumbered}`); + allParams.push(...fragment.params); + } else if (nowSet.has(key) && value === 'now') { setClauses.push(`"${snakeCol}" = ${dialect.now()}`); } else { allParams.push(value); From 36d7928cf5fed7763473c5d17c12360d32171192 Mon Sep 17 00:00:00 2001 From: Vinicius Dacal Date: Sat, 4 Apr 2026 20:10:59 -0300 Subject: [PATCH 3/6] test(db): add review-fix tests for DbExpr + fix autoUpdate readOnly bypass (#1743) - SQLite dialect tests for DbExpr in buildUpdate and buildInsert - Type-level tests (.test-d.ts) for DbExpr in $update and $update_input - Unit tests for renumberParamsWithDialect() - Regression test for autoUpdate + DbExpr interaction - Fix: allow DbExpr through readOnly filter on autoUpdate columns Co-Authored-By: Claude Opus 4.6 --- .../db/src/query/__tests__/crud-unit.test.ts | 21 ++++ packages/db/src/query/crud.ts | 20 +++- .../schema/__tests__/update-expr.test-d.ts | 97 +++++++++++++++++++ .../src/sql/__tests__/sqlite-builders.test.ts | 51 ++++++++++ packages/db/src/sql/__tests__/tagged.test.ts | 37 ++++++- 5 files changed, 221 insertions(+), 5 deletions(-) create mode 100644 packages/db/src/schema/__tests__/update-expr.test-d.ts diff --git a/packages/db/src/query/__tests__/crud-unit.test.ts b/packages/db/src/query/__tests__/crud-unit.test.ts index 9420f5476..f6fc25966 100644 --- a/packages/db/src/query/__tests__/crud-unit.test.ts +++ b/packages/db/src/query/__tests__/crud-unit.test.ts @@ -25,6 +25,7 @@ import { getOrThrow, update, updateMany, + upsert, } from '../crud'; import type { QueryFn } from '../executor'; @@ -161,6 +162,26 @@ describe('crud unit tests', () => { // updatedAt is autoUpdate — should appear in SET clause via NOW() expect(capturedSql).toContain('"updatedAt"'); }); + + it('does not overwrite user-provided DbExpr on autoUpdate column', async () => { + let capturedSql = ''; + let capturedParams: readonly unknown[] = []; + const queryFn: QueryFn = async (sql: string, params: readonly unknown[]) => { + capturedSql = sql; + capturedParams = params; + return { rows: [{ id: 'u1', name: 'X' }] as readonly T[], rowCount: 1 }; + }; + + await update(queryFn, usersTable, { + where: { id: 'u1' }, + data: { name: 'Updated', updatedAt: d.expr((col) => col) }, + }); + + // updatedAt should use the user-provided DbExpr, not the autoUpdate 'now' sentinel + // The SQL should contain the expression, NOT NOW() + expect(capturedSql).not.toContain('NOW()'); + expect(capturedSql).toContain('"updated_at"'); + }); }); describe('updateMany', () => { diff --git a/packages/db/src/query/crud.ts b/packages/db/src/query/crud.ts index 4cdacd51a..0c9ccd525 100644 --- a/packages/db/src/query/crud.ts +++ b/packages/db/src/query/crud.ts @@ -15,6 +15,7 @@ import { generateId } from '../id/generators'; import type { ColumnBuilder, ColumnMetadata } from '../schema/column'; import type { ColumnRecord, TableDef } from '../schema/table'; import { buildDelete } from '../sql/delete'; +import { isDbExpr } from '../sql/expr'; import { buildInsert } from '../sql/insert'; import { buildSelect } from '../sql/select'; import { buildUpdate } from '../sql/update'; @@ -370,8 +371,12 @@ export async function update( const readOnlyCols = getReadOnlyColumns(table); const autoUpdateCols = getAutoUpdateColumns(table); + // Strip readOnly columns, but let autoUpdate columns through when user provides a DbExpr + const autoUpdateSet = new Set(autoUpdateCols); const filteredData = Object.fromEntries( - Object.entries(options.data).filter(([key]) => !readOnlyCols.includes(key)), + Object.entries(options.data).filter( + ([key, val]) => !readOnlyCols.includes(key) || (autoUpdateSet.has(key) && isDbExpr(val)), + ), ); // Auto-set autoUpdate columns to NOW() unless the user already provided a @@ -424,8 +429,12 @@ export async function updateMany( const readOnlyCols = getReadOnlyColumns(table); const autoUpdateCols = getAutoUpdateColumns(table); + // Strip readOnly columns, but let autoUpdate columns through when user provides a DbExpr + const autoUpdateSet = new Set(autoUpdateCols); const filteredData = Object.fromEntries( - Object.entries(options.data).filter(([key]) => !readOnlyCols.includes(key)), + Object.entries(options.data).filter( + ([key, val]) => !readOnlyCols.includes(key) || (autoUpdateSet.has(key) && isDbExpr(val)), + ), ); // Auto-set autoUpdate columns to NOW() unless user provided a value @@ -493,9 +502,12 @@ export async function upsert( }), ); - // Strip readOnly fields from the update path, inject autoUpdate columns + // Strip readOnly fields from the update path, but let autoUpdate columns through with DbExpr + const autoUpdateSet = new Set(autoUpdateCols); const filteredUpdate = Object.fromEntries( - Object.entries(options.update).filter(([key]) => !readOnlyCols.includes(key)), + Object.entries(options.update).filter( + ([key, val]) => !readOnlyCols.includes(key) || (autoUpdateSet.has(key) && isDbExpr(val)), + ), ); for (const col of autoUpdateCols) { if (!(col in filteredUpdate)) { diff --git a/packages/db/src/schema/__tests__/update-expr.test-d.ts b/packages/db/src/schema/__tests__/update-expr.test-d.ts new file mode 100644 index 000000000..fdc71b956 --- /dev/null +++ b/packages/db/src/schema/__tests__/update-expr.test-d.ts @@ -0,0 +1,97 @@ +import { describe, it } from 'bun:test'; +import type { Equal, Expect, Extends } from '../../__tests__/_type-helpers'; +import { d } from '../../d'; +import type { DbExpr } from '../../sql/expr'; + +// --------------------------------------------------------------------------- +// Fixture +// --------------------------------------------------------------------------- + +const urls = d.table('urls', { + id: d.uuid().primary(), + slug: d.text().unique(), + target: d.text(), + clickCount: d.integer().default(0), + updatedAt: d.timestamp().default('now').autoUpdate(), +}); + +// --------------------------------------------------------------------------- +// $update accepts DbExpr for non-PK columns +// --------------------------------------------------------------------------- + +describe('$update accepts DbExpr', () => { + it('d.increment() is assignable to $update fields', () => { + type Update = typeof urls.$update; + const _valid: Update = { clickCount: d.increment(1) }; + void _valid; + }); + + it('d.decrement() is assignable to $update fields', () => { + type Update = typeof urls.$update; + const _valid: Update = { clickCount: d.decrement(5) }; + void _valid; + }); + + it('d.expr() is assignable to $update fields', () => { + type Update = typeof urls.$update; + const _valid: Update = { slug: d.expr((col) => col) }; + void _valid; + }); + + it('plain values remain assignable to $update fields', () => { + type Update = typeof urls.$update; + const _valid: Update = { clickCount: 42, slug: 'new-slug' }; + void _valid; + }); + + it('DbExpr is part of the $update value union', () => { + type Update = typeof urls.$update; + type ClickCountType = NonNullable; + type _t1 = Expect>; + type _t2 = Expect>; + }); +}); + +// --------------------------------------------------------------------------- +// $update_input accepts DbExpr +// --------------------------------------------------------------------------- + +describe('$update_input accepts DbExpr', () => { + it('d.increment() is assignable to $update_input fields', () => { + type UpdateInput = typeof urls.$update_input; + const _valid: UpdateInput = { clickCount: d.increment(1) }; + void _valid; + }); + + it('plain values remain assignable to $update_input fields', () => { + type UpdateInput = typeof urls.$update_input; + const _valid: UpdateInput = { clickCount: 10 }; + void _valid; + }); + + it('DbExpr is part of $update_input value union', () => { + type UpdateInput = typeof urls.$update_input; + type ClickCountType = NonNullable; + type _t1 = Expect>; + }); +}); + +// --------------------------------------------------------------------------- +// Negative tests — rejects wrong types +// --------------------------------------------------------------------------- + +describe('$update rejects invalid types', () => { + it('rejects Prisma-style { increment: 1 } objects', () => { + type Update = typeof urls.$update; + // @ts-expect-error — Prisma-style increment objects are not valid + const _invalid: Update = { clickCount: { increment: 1 } }; + void _invalid; + }); + + it('rejects wrong primitive type', () => { + type Update = typeof urls.$update; + // @ts-expect-error — clickCount is number | DbExpr, not string + const _invalid: Update = { clickCount: 'not a number' }; + void _invalid; + }); +}); diff --git a/packages/db/src/sql/__tests__/sqlite-builders.test.ts b/packages/db/src/sql/__tests__/sqlite-builders.test.ts index b90075bdc..e92791587 100644 --- a/packages/db/src/sql/__tests__/sqlite-builders.test.ts +++ b/packages/db/src/sql/__tests__/sqlite-builders.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it } from 'bun:test'; +import { d } from '../../d'; import { defaultPostgresDialect, SqliteDialect } from '../../dialect'; import { buildDelete } from '../delete'; import { buildInsert } from '../insert'; import { buildSelect } from '../select'; +import { sql } from '../tagged'; import { buildUpdate } from '../update'; import { buildWhere } from '../where'; @@ -60,6 +62,27 @@ describe('buildInsert with SqliteDialect', () => { ); expect(result.params).toEqual(['123', 'Alice']); }); + + it('generates ? params for ON CONFLICT with DbExpr updateValues', () => { + const result = buildInsert( + { + table: 'urls', + data: { slug: 'test', clickCount: 1 }, + onConflict: { + columns: ['slug'], + action: 'update', + updateColumns: ['clickCount'], + updateValues: { clickCount: d.increment(1) }, + }, + }, + sqliteDialect, + ); + + expect(result.sql).toBe( + 'INSERT INTO "urls" ("slug", "click_count") VALUES (?, ?) ON CONFLICT ("slug") DO UPDATE SET "click_count" = "click_count" + ?', + ); + expect(result.params).toEqual(['test', 1, 1]); + }); }); describe('buildSelect with SqliteDialect', () => { @@ -128,6 +151,34 @@ describe('buildUpdate with SqliteDialect', () => { ); expect(result.params).toEqual(['123']); }); + + it('generates ? params for DbExpr d.increment()', () => { + const result = buildUpdate( + { + table: 'counters', + data: { count: d.increment(1) }, + where: { id: { eq: 'c1' } }, + }, + sqliteDialect, + ); + + expect(result.sql).toBe('UPDATE "counters" SET "count" = "count" + ? WHERE "id" = ?'); + expect(result.params).toEqual([1, 'c1']); + }); + + it('generates ? params for DbExpr d.expr()', () => { + const result = buildUpdate( + { + table: 'urls', + data: { slug: d.expr((col) => sql`LOWER(${col})`) }, + where: { id: { eq: 'u1' } }, + }, + sqliteDialect, + ); + + expect(result.sql).toBe('UPDATE "urls" SET "slug" = LOWER("slug") WHERE "id" = ?'); + expect(result.params).toEqual(['u1']); + }); }); describe('buildDelete with SqliteDialect', () => { diff --git a/packages/db/src/sql/__tests__/tagged.test.ts b/packages/db/src/sql/__tests__/tagged.test.ts index f9fd720cc..588119dbb 100644 --- a/packages/db/src/sql/__tests__/tagged.test.ts +++ b/packages/db/src/sql/__tests__/tagged.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'bun:test'; -import { sql } from '../tagged'; +import { renumberParamsWithDialect, sql } from '../tagged'; describe('sql tagged template', () => { describe('basic parameterization', () => { @@ -155,3 +155,38 @@ describe('sql tagged template', () => { }); }); }); + +describe('renumberParamsWithDialect', () => { + const postgresDialect = { param: (i: number) => `$${i}` }; + const sqliteDialect = { param: () => '?' }; + + it('renumbers $1, $2 with postgres dialect and offset 0', () => { + const result = renumberParamsWithDialect('"col" + $1', 0, postgresDialect); + expect(result).toBe('"col" + $1'); + }); + + it('renumbers $1 with postgres dialect and offset 3', () => { + const result = renumberParamsWithDialect('"col" + $1', 3, postgresDialect); + expect(result).toBe('"col" + $4'); + }); + + it('renumbers multiple params with offset', () => { + const result = renumberParamsWithDialect('$1 + $2', 5, postgresDialect); + expect(result).toBe('$6 + $7'); + }); + + it('converts to ? placeholders with sqlite dialect', () => { + const result = renumberParamsWithDialect('"col" + $1', 0, sqliteDialect); + expect(result).toBe('"col" + ?'); + }); + + it('converts multiple params to ? with sqlite dialect', () => { + const result = renumberParamsWithDialect('$1 + $2', 0, sqliteDialect); + expect(result).toBe('? + ?'); + }); + + it('returns unchanged string when no params present', () => { + const result = renumberParamsWithDialect('LOWER("slug")', 0, postgresDialect); + expect(result).toBe('LOWER("slug")'); + }); +}); From 066f0714921fd4caa7eb7bfcf367147d7d2d1a35 Mon Sep 17 00:00:00 2001 From: Vinicius Dacal Date: Sat, 4 Apr 2026 20:12:18 -0300 Subject: [PATCH 4/6] docs(db): document update expressions API (#1743) Add documentation for d.expr(), d.increment(), d.decrement() in the queries guide. Update sql import path to vertz/db. Co-Authored-By: Claude Opus 4.6 --- packages/mint-docs/guides/db/queries.mdx | 36 +++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/packages/mint-docs/guides/db/queries.mdx b/packages/mint-docs/guides/db/queries.mdx index 1b1d03894..d13343965 100644 --- a/packages/mint-docs/guides/db/queries.mdx +++ b/packages/mint-docs/guides/db/queries.mdx @@ -209,6 +209,40 @@ const result = await db.users.update({ }); ``` +### Update expressions + +Use `d.increment()`, `d.decrement()`, or `d.expr()` to perform atomic column operations directly in the database — no read-modify-write cycle needed. + +```ts +import { d, sql } from 'vertz/db'; + +// Increment a counter atomically +await db.urls.update({ + where: { slug: 'my-link' }, + data: { clickCount: d.increment(1) }, +}); +// SQL: UPDATE "urls" SET "click_count" = "click_count" + $1 WHERE ... + +// Decrement +await db.inventory.update({ + where: { sku: 'WIDGET-01' }, + data: { stock: d.decrement(5) }, +}); + +// Arbitrary SQL expression +await db.urls.update({ + where: { slug: 'My-Link' }, + data: { slug: d.expr((col) => sql`LOWER(${col})`) }, +}); +// SQL: UPDATE "urls" SET "slug" = LOWER("slug") WHERE ... +``` + +Expressions work in `update`, `updateMany`, and the `update` path of `upsert`. They compose with the `sql` tagged template — the `col` parameter in `d.expr()` is the column reference, automatically converted to snake_case. + + + Expressions override `autoUpdate()` columns. If you pass `d.increment()` on an `autoUpdate()` column like `updatedAt`, your expression is used instead of the automatic `NOW()`. + + ### Upsert Create if not found, update if exists: @@ -329,7 +363,7 @@ if (!result.ok) { For queries that the builder can't express, use tagged template SQL: ```ts -import { sql } from 'vertz/db/sql'; +import { sql } from 'vertz/db'; const result = await db.query(sql`SELECT * FROM users WHERE email = ${email} AND active = ${true}`); ``` From d61ad983d534263fa816e9990d23024d03f1ccca Mon Sep 17 00:00:00 2001 From: Vinicius Dacal Date: Sat, 4 Apr 2026 20:12:45 -0300 Subject: [PATCH 5/6] chore(db): add changeset, design doc, and review for update expressions (#1743) Co-Authored-By: Claude Opus 4.6 --- .changeset/db-update-expressions.md | 7 + plans/1743-db-update-expressions.md | 481 ++++++++++++++++++ .../db-update-expressions/phase-01-02-impl.md | 163 ++++++ 3 files changed, 651 insertions(+) create mode 100644 .changeset/db-update-expressions.md create mode 100644 plans/1743-db-update-expressions.md create mode 100644 reviews/db-update-expressions/phase-01-02-impl.md diff --git a/.changeset/db-update-expressions.md b/.changeset/db-update-expressions.md new file mode 100644 index 000000000..f94b63b63 --- /dev/null +++ b/.changeset/db-update-expressions.md @@ -0,0 +1,7 @@ +--- +'@vertz/db': patch +--- + +feat(db): add atomic update expressions — d.expr(), d.increment(), d.decrement() + +Enables atomic column operations in update/upsert without read-modify-write cycles. Supports arbitrary SQL expressions via `d.expr(col => sql`...`)`, with `d.increment(n)` and `d.decrement(n)` as sugar. Works across PostgreSQL and SQLite dialects. diff --git a/plans/1743-db-update-expressions.md b/plans/1743-db-update-expressions.md new file mode 100644 index 000000000..0942d2465 --- /dev/null +++ b/plans/1743-db-update-expressions.md @@ -0,0 +1,481 @@ +# Update Expressions for `@vertz/db` + +**Issue:** #1743 +**Status:** Approved — implementing +**Date:** 2026-04-04 + +## Problem + +`@vertz/db`'s `update()` only supports direct value assignments. Atomic operations like `SET click_count = click_count + 1` require dropping to raw SQL, which bypasses the typed client, loses camelCase mapping, and skips autoUpdate columns. + +The original issue proposed Prisma-style `{ increment: 1 }` objects. This design expands the scope: instead of special-casing arithmetic, we provide a **general-purpose column expression** that covers increment, decrement, SQL functions, and anything else — through a single primitive. + +## API Surface + +### Core: `d.expr()` — column-relative SQL expression + +```typescript +import { d } from '@vertz/db'; +import { sql } from '@vertz/db/sql'; + +// Atomic increment +db.urls.update({ + where: { id }, + data: { + clickCount: d.expr((col) => sql`${col} + ${1}`), + }, +}); +// → UPDATE "urls" SET "click_count" = "click_count" + $1 WHERE "id" = $2 + +// SQL function +db.urls.update({ + where: { id }, + data: { + slug: d.expr((col) => sql`LOWER(${col})`), + }, +}); +// → UPDATE "urls" SET "slug" = LOWER("slug") WHERE "id" = $1 + +// Complex expression with parameters +db.scores.update({ + where: { id }, + data: { + score: d.expr((col) => sql`GREATEST(${col} - ${penalty}, ${0})`), + }, +}); +// → UPDATE "scores" SET "score" = GREATEST("score" - $1, $2) WHERE "id" = $3 + +// JSONB merge +db.configs.update({ + where: { id }, + data: { + settings: d.expr((col) => sql`${col} || ${JSON.stringify({ theme: 'dark' })}::jsonb`), + }, +}); +``` + +The `col` callback parameter is a `SqlFragment` containing the quoted, snake_case column reference (`"click_count"`). Users never need to know the DB column name — it's derived from the camelCase key. + +### Convenience shortcuts + +For the most common operations (arithmetic), provide named helpers built on `d.expr()`: + +```typescript +import { d } from '@vertz/db'; + +db.urls.update({ + where: { id }, + data: { + clickCount: d.increment(1), + // → SET "click_count" = "click_count" + $1 + }, +}); + +db.products.update({ + where: { id }, + data: { + stock: d.decrement(quantity), + // → SET "stock" = "stock" - $1 + }, +}); + +// For multiply/divide, use d.expr() directly: +db.accounts.update({ + where: { id }, + data: { + balance: d.expr((col) => sql`${col} * ${1.05}`), + // → SET "balance" = "balance" * $1 + }, +}); +``` + +### Mixed usage — expressions and direct values together + +```typescript +db.urls.update({ + where: { id }, + data: { + clickCount: d.increment(1), // expression + lastClickedAt: new Date(), // direct value + // updatedAt is auto-set via autoUpdate (existing behavior) + }, +}); +// → UPDATE "urls" +// SET "click_count" = "click_count" + $1, +// "last_clicked_at" = $2, +// "updated_at" = NOW() +// WHERE "id" = $3 +// RETURNING ... +``` + +### Works with `upsert()` too + +Expressions work in the `update` path of upsert (ON CONFLICT DO UPDATE SET): + +```typescript +db.urls.upsert({ + where: { slug: 'test' }, + create: { slug: 'test', target: 'https://example.com', clickCount: 1 }, + update: { clickCount: d.increment(1) }, +}); +// → INSERT INTO "urls" (...) VALUES (...) +// ON CONFLICT ("slug") DO UPDATE SET "click_count" = "click_count" + $N +``` + +### Works with `updateMany()` too + +```typescript +db.products.updateMany({ + where: { categoryId }, + data: { + price: d.expr((col) => sql`${col} * ${1.1}`), // 10% price increase for a category + }, +}); +``` + +### Runtime representation + +```typescript +// packages/db/src/sql/expr.ts + +import type { SqlFragment } from './tagged'; + +export interface DbExpr { + readonly _tag: 'DbExpr'; + readonly build: (columnRef: SqlFragment) => SqlFragment; +} + +export function isDbExpr(value: unknown): value is DbExpr { + return ( + typeof value === 'object' && + value !== null && + '_tag' in value && + (value as DbExpr)._tag === 'DbExpr' + ); +} +``` + +### Type changes + +The update input types widen to accept `DbExpr` alongside the column's inferred type: + +```typescript +// packages/db/src/schema/table.ts + +import type { DbExpr } from '../sql/expr'; + +type Update = { + [K in ColumnKeysWhereNot]?: InferColumnType | DbExpr; +}; + +type ApiUpdateInput = { + [K in ColumnKeysWhereNot & + ColumnKeysWhereNot & + string]?: InferColumnType | DbExpr; +}; +``` + +### `d` namespace additions + +```typescript +// Added to packages/db/src/d.ts + +import { sql } from './sql/tagged'; +import type { DbExpr } from './sql/expr'; + +// On the `d` object: +expr(build: (col: SqlFragment) => SqlFragment): DbExpr; +increment(value: number): DbExpr; +decrement(value: number): DbExpr; +``` + +Implementation: + +```typescript +expr: (build) => ({ _tag: 'DbExpr', build }), +increment: (n) => ({ _tag: 'DbExpr', build: (col) => sql`${col} + ${n}` }), +decrement: (n) => ({ _tag: 'DbExpr', build: (col) => sql`${col} - ${n}` }), +``` + +### Public exports + +```typescript +// packages/db/src/index.ts — add: +export type { DbExpr } from './sql/expr'; +export { isDbExpr } from './sql/expr'; +export { sql } from './sql/tagged'; // re-export so d.expr() doesn't need a second import path + +// packages/db/src/sql/index.ts — add: +export type { DbExpr } from './expr'; +export { isDbExpr } from './expr'; +``` + +## Manifesto Alignment + +### One Way to Do Things +`d.expr()` is the single primitive. Shortcuts (`d.increment`, etc.) are sugar built on top — not alternative patterns. There's one mechanism, one detection path, one way the SQL is composed. + +### Explicit Over Implicit +The callback `(col) => sql`${col} + ${1}`` makes the SQL transformation visible. No hidden behavior — you can read exactly what SQL will be generated. Compare to Prisma's `{ increment: 1 }` where the mapping is opaque. + +### LLM-First +- `d.increment(1)` is obvious — an LLM will get it right on first prompt +- `d.expr(col => sql`${col} + ${1}`)` is composable and follows existing `sql` template patterns +- No snake_case knowledge needed — `col` is auto-derived + +### If It Builds, It Works +`DbExpr` is accepted on any column type. While we could add `NumericDbExpr` that only accepts numeric columns, the complexity isn't worth it pre-v1. The DB will error on type mismatches (e.g., `d.increment(1)` on a text column), which is a clear, immediate error — not a silent corruption. + +### Functions Over Decorators +`d.expr()` is a pure function returning a data descriptor. No decorators, no class inheritance, no magic. + +## Alternatives Rejected + +### 1. Prisma-style `{ increment: 1 }` objects +```typescript +data: { clickCount: { increment: 1 } } +``` +**Rejected because:** Overfits to arithmetic. Can't express `UPPER(col)`, `COALESCE(col, 0)`, JSONB merge, or any non-arithmetic operation. Would need a new special case for each SQL function. Also ambiguous with JSONB columns where `{ increment: 1 }` could be a valid JSON value. + +### 2. Bare `SqlFragment` as value (no self-reference) +```typescript +data: { clickCount: sql`"click_count" + ${1}` } +``` +**Rejected because:** Requires knowing the snake_case column name. Duplicates the column reference (key + SQL). Error-prone — typo in the column name is a silent bug. Violates "if it builds, it works." + +### 3. Magic `d.self` marker in SQL template +```typescript +data: { clickCount: sql`${d.self} + ${1}` } +``` +**Rejected because:** `d.self` is a global marker resolved at build time — implicit and magical. The callback pattern `d.expr(col => ...)` is explicit about scope. + +## Non-Goals + +1. **INSERT expressions** — `d.expr()` references the current column value, which doesn't exist during INSERT. Timestamp defaults (`'now'` sentinel) are a separate mechanism and out of scope. +2. **Cross-column references** — `d.expr()` only exposes the current column. Cross-column SET (e.g., `SET a = b + 1`) is rare in app code and can use raw `db.query(sql`...`)`. +3. **Typed expression constraints** — No `NumericDbExpr` that restricts to numeric columns. The DB validates types at execution time, which is sufficient pre-v1. +4. **Dialect-specific functions** — The `sql` template composes raw SQL strings. Users are responsible for dialect-appropriate SQL within `d.expr()`. The shortcuts (`increment`, etc.) use standard SQL operators that work across PostgreSQL and SQLite. +5. **Replacing the `'now'` sentinel** — The existing `autoUpdate` / `'now'` pattern works and is internal. Replacing it with `d.expr()` would be a nice cleanup but is a separate concern. + +## Unknowns + +None identified. The implementation touches well-understood code paths (`buildUpdate`, `crud.update`, `crud.updateMany`) and the `SqlFragment` composition mechanism is already battle-tested in the `sql` tagged template. + +## Review Findings (addressed) + +### 1. `renumberParams` not exported (Technical — blocker) + +`renumberParams()` in `tagged.ts` is private. `buildUpdate()` needs it to compose expression fragments. **Resolution:** Export a dialect-aware variant `renumberParamsWithDialect()` that replaces `$N` with `dialect.param(offset + N)`. This ensures SQLite compatibility (`?` instead of `$N`). + +```typescript +// In tagged.ts — new export +export function renumberParamsWithDialect( + sqlStr: string, + offset: number, + dialect: Dialect, +): string { + let counter = 0; + return sqlStr.replace(/\$(\d+)/g, () => { + counter++; + return dialect.param(offset + counter); + }); +} +``` + +### 2. AutoUpdate column overwrite (Technical — should-fix) + +`crud.update()` unconditionally injects `'now'` for autoUpdate columns, which would overwrite user-provided `DbExpr` values. **Resolution:** Check if the user already provided a value before injecting: + +```typescript +for (const col of autoUpdateCols) { + if (!(col in filteredData)) { + filteredData[col] = 'now'; + } +} +``` + +### 3. Dialect-aware param renumbering (Technical — should-fix) + +Expression fragments use `$N` format from the `sql` template, but SQLite expects `?`. **Resolution:** Addressed by finding #1 — `renumberParamsWithDialect()` translates to the target dialect. + +### 5. Drop `d.multiply()` and `d.divide()` (DX — should-fix) + +The arithmetic shortcuts beyond +/- are speculative. No real use case demanded them yet. `d.expr(col => sql`${col} * ${factor}`)` covers multiply/divide clearly. Fewer shortcuts = less autocomplete noise, less LLM confusion about "which one to use." **Resolution:** Ship only `d.increment()`, `d.decrement()`, and `d.expr()`. Add multiply/divide later when demanded. + +### 6. Re-export `sql` from main `@vertz/db` (DX — should-fix) + +`d.expr()` requires the `sql` tagged template, which currently lives at `@vertz/db/sql`. This is the only `d.*` API that needs a second import. **Resolution:** Re-export `sql` from the main `@vertz/db` entrypoint so developers can write `import { d, sql } from '@vertz/db'`. The `@vertz/db/sql` subpath stays for users who only need the SQL builder. + +### 7. Upsert ON CONFLICT SET must handle `DbExpr` (Product — should-fix) + +`upsert()` passes update values to `buildInsert()`'s `onConflict.updateValues`. The ON CONFLICT SET clause in `buildInsert()` pushes values directly as params — a `DbExpr` would be serialized as an object, producing a SQL error. **Resolution:** Add the same `isDbExpr()` detection in `buildInsert()`'s ON CONFLICT SET clause. Same pattern as `buildUpdate()`. Also fix autoUpdate overwrite in `upsert()` (same `if (!(col in filteredUpdate))` guard). + +### 8. `DbExpr` check ordering in `buildUpdate` (Technical — implementation note) + +`isDbExpr(value)` must be checked **before** the `'now'` sentinel check to ensure expressions take precedence: + +```typescript +if (isDbExpr(value)) { + // compose expression fragment +} else if (nowSet.has(key) && value === 'now') { + // timestamp sentinel +} else { + // direct value +} +``` + +## POC Results + +Not applicable — the change is localized (SET clause builder + type widening) and doesn't introduce new architectural patterns. + +## Type Flow Map + +``` +d.expr(fn) → DbExpr { _tag, build } +d.increment(n) → DbExpr { _tag, build } + ↓ +User passes in data: { clickCount: DbExpr } + ↓ +ApiUpdateInput accepts InferColumnType | DbExpr +$update type accepts InferColumnType | DbExpr + ↓ +crud.update() passes data to buildUpdate() + ↓ +buildUpdate() detects isDbExpr(value) + calls value.build(sql.raw('"column_name"')) + gets SqlFragment back + inlines SQL + renumbers params + ↓ +SQL output "click_count" = "click_count" + $1 +``` + +### Type tests (`.test-d.ts`) + +```typescript +import { d } from '@vertz/db'; +import { sql } from '@vertz/db/sql'; + +declare const urlTable: TableDef<{ + id: ColumnBuilder; + clickCount: ColumnBuilder>; + slug: ColumnBuilder>; +}>; + +type UrlUpdate = (typeof urlTable)['$update']; + +// Positive: direct values still work +const directUpdate: UrlUpdate = { clickCount: 5 }; + +// Positive: DbExpr works on any column +const exprUpdate: UrlUpdate = { clickCount: d.increment(1) }; +const strExprUpdate: UrlUpdate = { slug: d.expr((col) => sql`LOWER(${col})`) }; + +// Positive: mixed direct + expr +const mixedUpdate: UrlUpdate = { clickCount: d.increment(1), slug: 'new-slug' }; + +// @ts-expect-error — primary key not in $update +const pkUpdate: UrlUpdate = { id: 'new-id' }; +``` + +## E2E Acceptance Test + +From a developer's perspective, using the public `@vertz/db` API: + +```typescript +import { d, createDb, sql } from '@vertz/db'; + +// Schema +const urlsTable = d.table('urls', { + id: d.uuid().primary(), + slug: d.text().unique(), + target: d.text(), + clickCount: d.integer().default(0), + updatedAt: d.timestamp().autoUpdate(), +}); + +const Url = d.model(urlsTable); +const db = createDb({ /* ... */ }); + +// Seed +const url = await db.urls.create({ + data: { slug: 'test', target: 'https://example.com' }, +}); +expect(url.clickCount).toBe(0); + +// Atomic increment +const updated = await db.urls.update({ + where: { id: url.id }, + data: { clickCount: d.increment(1) }, +}); +expect(updated.clickCount).toBe(1); + +// Multiple increments +const again = await db.urls.update({ + where: { id: url.id }, + data: { clickCount: d.increment(5) }, +}); +expect(again.clickCount).toBe(6); + +// Decrement +const decremented = await db.urls.update({ + where: { id: url.id }, + data: { clickCount: d.decrement(2) }, +}); +expect(decremented.clickCount).toBe(4); + +// General expression +const uppercased = await db.urls.update({ + where: { id: url.id }, + data: { slug: d.expr((col) => sql`UPPER(${col})`) }, +}); +expect(uppercased.slug).toBe('TEST'); + +// Mixed direct values + expressions +const mixed = await db.urls.update({ + where: { id: url.id }, + data: { + clickCount: d.increment(1), + target: 'https://new-target.com', + }, +}); +expect(mixed.clickCount).toBe(5); +expect(mixed.target).toBe('https://new-target.com'); + +// Upsert with expression in update path +const upserted = await db.urls.upsert({ + where: { slug: 'TEST' }, + create: { slug: 'TEST', target: 'https://example.com', clickCount: 1 }, + update: { clickCount: d.increment(1) }, +}); +expect(upserted.clickCount).toBe(6); // existing row was incremented + +// updateMany with expressions +await db.urls.updateMany({ + where: { slug: 'TEST' }, + data: { clickCount: d.expr((col) => sql`${col} * ${2}`) }, +}); +const final = await db.urls.findFirst({ where: { id: url.id } }); +expect(final!.clickCount).toBe(10); + +// @ts-expect-error — wrong: plain object is not a valid expression +await db.urls.update({ + where: { id: url.id }, + data: { clickCount: { increment: 1 } }, +}); +``` + +## Files Changed + +| File | Change | +|------|--------| +| `packages/db/src/sql/expr.ts` | **New** — `DbExpr` interface, `isDbExpr()`, `expr()`, arithmetic shortcuts | +| `packages/db/src/sql/tagged.ts` | Export `renumberParamsWithDialect()` for dialect-aware fragment composition | +| `packages/db/src/sql/insert.ts` | Detect `DbExpr` values in ON CONFLICT SET clause (upsert path) | +| `packages/db/src/sql/update.ts` | Detect `DbExpr` values, compose SQL fragments in SET clause | +| `packages/db/src/sql/index.ts` | Re-export `DbExpr`, `isDbExpr` | +| `packages/db/src/d.ts` | Add `expr`, `increment`, `decrement`, `multiply`, `divide` to `d` | +| `packages/db/src/query/crud.ts` | Fix autoUpdate injection to not overwrite user-provided expressions | +| `packages/db/src/schema/table.ts` | Widen `$update` and `$update_input` types to accept `DbExpr` | +| `packages/db/src/index.ts` | Re-export `DbExpr` type and `isDbExpr` | +| `packages/db/src/sql/__tests__/update.test.ts` | Tests for expression handling in `buildUpdate` | +| `packages/db/src/sql/__tests__/expr.test.ts` | **New** — Unit tests for `DbExpr`, shortcuts, `isDbExpr` | diff --git a/reviews/db-update-expressions/phase-01-02-impl.md b/reviews/db-update-expressions/phase-01-02-impl.md new file mode 100644 index 000000000..94a8f406b --- /dev/null +++ b/reviews/db-update-expressions/phase-01-02-impl.md @@ -0,0 +1,163 @@ +# Phase 1-2: DbExpr Primitives + SQL Builder Integration + +- **Author:** claude +- **Reviewer:** claude (adversarial) +- **Commits:** 663746503..b543fff53 +- **Date:** 2026-04-04 + +## Changes + +- `packages/db/src/sql/expr.ts` (new) — `DbExpr` interface, `isDbExpr()` type guard +- `packages/db/src/sql/tagged.ts` (modified) — added `renumberParamsWithDialect()` +- `packages/db/src/sql/update.ts` (modified) — `DbExpr` detection in SET clause +- `packages/db/src/sql/insert.ts` (modified) — `DbExpr` detection in ON CONFLICT SET clause +- `packages/db/src/query/crud.ts` (modified) — autoUpdate injection guard (`if (!(col in filteredData))`) +- `packages/db/src/d.ts` (modified) — `expr`, `increment`, `decrement` on `d` namespace +- `packages/db/src/schema/table.ts` (modified) — widened `$update` and `$update_input` to accept `DbExpr` +- `packages/db/src/index.ts` (modified) — re-exported `DbExpr`, `isDbExpr`, `sql`, `SqlFragment` +- `packages/db/src/sql/index.ts` (modified) — re-exported `DbExpr`, `isDbExpr` +- `packages/db/src/sql/__tests__/expr.test.ts` (new) — unit tests for `DbExpr`, `isDbExpr`, shortcuts +- `packages/db/src/sql/__tests__/update.test.ts` (modified) — DbExpr tests for `buildUpdate` +- `packages/db/src/sql/__tests__/insert.test.ts` (modified) — DbExpr tests for `buildInsert` ON CONFLICT + +## CI Status + +- [ ] Quality gates passed at commit (not yet verified by reviewer) + +## Review Checklist + +- [x] Delivers what the ticket asks for +- [ ] TDD compliance (tests before/alongside implementation) — see findings +- [ ] No type gaps or missing edge cases — see findings +- [x] No security issues (injection, XSS, etc.) +- [x] Public API changes match design doc + +## Findings + +### SHOULD-FIX 1: No SQLite dialect tests for DbExpr in buildUpdate or buildInsert + +**Severity:** SHOULD-FIX + +The `sqlite-builders.test.ts` file has no tests for `DbExpr` expressions with the SQLite dialect. The `renumberParamsWithDialect()` function was specifically created to handle dialect-aware param translation (replacing `$N` with `?` for SQLite), but this critical path is completely untested with the SQLite dialect. + +Since `d.increment(1)` produces a fragment with `$1` from the `sql` tagged template, and SQLite needs `?`, this is the exact scenario where a bug would silently produce wrong SQL. The code looks correct from reading, but without a test this guarantee is fragile. + +**Required tests in `sqlite-builders.test.ts`:** +- `d.increment()` in `buildUpdate` with SQLite dialect +- `d.expr()` with params in `buildUpdate` with SQLite dialect +- `d.increment()` in `buildInsert` ON CONFLICT with SQLite dialect + +### SHOULD-FIX 2: No type-level tests (.test-d.ts) for DbExpr in $update / $update_input + +**Severity:** SHOULD-FIX + +The design doc specifies a type flow map and lists specific `.test-d.ts` assertions (direct values work, `DbExpr` works, mixed usage, PK excluded). The existing `table.test-d.ts` has no `DbExpr`-related assertions. The TDD rules require `.test-d.ts` tests for every generic that flows from definition to consumer. + +Specifically missing: +- Positive: `d.increment(1)` assignable to `$update.clickCount` +- Positive: `d.expr(col => sql`...`)` assignable to `$update.slug` +- Positive: mixed direct + expr in `$update` +- Positive: `DbExpr` assignable to `$update_input` fields +- Negative: `@ts-expect-error` — `{ increment: 1 }` plain object not assignable to `$update` column (Prisma-style is rejected) + +### SHOULD-FIX 3: No crud-level test for autoUpdate + DbExpr interaction + +**Severity:** SHOULD-FIX + +The `crud.ts` fix (lines 381-385 in `update`, lines 432-436 in `updateMany`, lines 500-504 in `upsert`) changes the autoUpdate injection from unconditional to conditional (`if (!(col in filteredData))`). This is the key behavioral fix in the issue, but it has no dedicated test at the crud layer. + +The `crud-unit.test.ts` file has a test for autoUpdate injection (`'injects autoUpdate columns with "now" sentinel'`) but no test verifying that user-provided `DbExpr` values are NOT overwritten. A regression here would silently replace user expressions with `NOW()`. + +**Required test:** `update()` with `data: { updatedAt: d.expr((col) => sql`${col} + INTERVAL '1 day'`) }` on a table with `autoUpdate()` on `updatedAt` should NOT inject `'now'` — the SQL should contain the user's expression, not `NOW()`. + +### SHOULD-FIX 4: `renumberParamsWithDialect` has no unit tests + +**Severity:** SHOULD-FIX + +`renumberParamsWithDialect` is a new public export from `tagged.ts`. The `tagged.test.ts` file has no tests for it. While its behavior is exercised indirectly through `buildUpdate`/`buildInsert` tests, those only cover the Postgres dialect (which uses the same `$N` format as the input). The function's raison d'etre is dialect translation, which is untested in isolation. + +**Required tests:** +- Postgres dialect: `renumberParamsWithDialect('$1 + $2', 3, pgDialect)` => `'$4 + $5'` +- SQLite dialect: `renumberParamsWithDialect('$1 + $2', 3, sqliteDialect)` => `'? + ?'` +- Zero offset: `renumberParamsWithDialect('$1', 0, pgDialect)` => `'$1'` +- No params: `renumberParamsWithDialect('NOW()', 5, pgDialect)` => `'NOW()'` + +### NIT 1: `isDbExpr` type guard uses cast chain + +**Severity:** NIT + +```typescript +export function isDbExpr(value: unknown): value is DbExpr { + return ( + typeof value === 'object' && + value !== null && + '_tag' in value && + (value as DbExpr)._tag === 'DbExpr' + ); +} +``` + +The `'_tag' in value` check already narrows `value` to `object & Record<'_tag', unknown>`. The `(value as DbExpr)._tag` cast is unnecessary — `value._tag === 'DbExpr'` would suffice after the `in` check. Not a correctness issue, just slightly noisier than needed. + +### NIT 2: `colRef` constructed inline instead of using `sql.raw()` + +**Severity:** NIT + +In both `buildUpdate` (line 63) and `buildInsert` (lines 106-110), the column reference `SqlFragment` is constructed manually: + +```typescript +const colRef: SqlFragment = { _tag: 'SqlFragment', sql: `"${snakeCol}"`, params: [] }; +``` + +`sql.raw(`"${snakeCol}"`)` would produce the same object and be more idiomatic within the codebase. This is cosmetic — both approaches are functionally identical. + +### NIT 3: Design doc mentions `d.multiply()` and `d.divide()` in files changed table + +**Severity:** NIT + +The design doc's "Files Changed" table says `d.ts` adds "expr(), increment(), arithmetic shortcuts" but the "Review Findings" section confirmed multiply/divide were dropped. The implementation correctly only ships `expr`, `increment`, `decrement`. The doc is slightly stale in the table but the resolution section is accurate. + +### OBSERVATION: Security model is sound + +The `DbExpr.build()` callback receives a `SqlFragment` and must return a `SqlFragment`. Since `SqlFragment` can only be constructed via the `sql` tagged template (which auto-parameterizes interpolated values) or `sql.raw()` (which is documented as unsafe), the expression system inherits the same injection safety as the rest of the SQL builder layer. User-controlled values are always parameterized. Column names derived from object keys go through `camelToSnake()` and are double-quoted — safe against injection. + +### OBSERVATION: Correctness of parameter numbering + +Traced through the increment scenario manually: + +1. `d.increment(1)` creates `DbExpr` with `build: (col) => sql`${col} + ${n}`` +2. `buildUpdate` creates `colRef` as `SqlFragment { sql: '"click_count"', params: [] }` +3. `build(colRef)` evaluates the tagged template: `col` is a `SqlFragment` so it's inlined, `n` (1) is parameterized +4. Result: `SqlFragment { sql: '"click_count" + $1', params: [1] }` +5. `renumberParamsWithDialect('"click_count" + $1', 0, pgDialect)` => `'"click_count" + $1'` (offset 0 + counter 1 = $1) +6. `allParams` gets `[1]`, so next param index is 2 + +For a mixed case (increment + direct value + WHERE): +- SET: `"click_count" = "click_count" + $1` (params: [1]) +- SET: `"target" = $2` (params: [1, 'https://...']) +- WHERE: `"id" = $3` (params: [1, 'https://...', 'u1']) + +This matches the test expectation at line 179 of `update.test.ts`. Parameter numbering is correct. + +### OBSERVATION: Expression check ordering is correct + +In `buildUpdate`, `isDbExpr(value)` is checked before `nowSet.has(key) && value === 'now'`. This means if a user provides a `DbExpr` for an autoUpdate column, the expression takes precedence over the `'now'` sentinel. Test at line 184 confirms this. Good. + +## Summary + +**Verdict: Approved with SHOULD-FIX items** + +The implementation is correct, well-structured, and matches the design doc. The security model is sound, parameter numbering is correct, and the API design is clean. + +However, there are 4 SHOULD-FIX items that should be addressed before merging: + +1. **SQLite dialect tests** for DbExpr in `buildUpdate`/`buildInsert` — the dialect translation path is untested +2. **Type-level tests** (`.test-d.ts`) for `DbExpr` in `$update`/`$update_input` — required by project TDD rules +3. **Crud-level test** for autoUpdate + DbExpr non-overwrite — the key bug fix has no dedicated regression test +4. **Unit tests** for `renumberParamsWithDialect` — new public function with no direct tests + +None of these are blockers (the code is correct from manual review), but they represent test gaps that violate the project's 95%+ coverage target and TDD compliance rules. Fix these before proceeding to documentation. + +## Resolution + +_Pending — awaiting author fixes._ From 24f034d6f04e2359278fce23122ff45283ba0a81 Mon Sep 17 00:00:00 2001 From: Vinicius Dacal Date: Sat, 4 Apr 2026 20:19:01 -0300 Subject: [PATCH 6/6] style(db): fix oxfmt formatting (#1743) Co-Authored-By: Claude Opus 4.6 --- packages/db/src/schema/table.ts | 6 +++--- packages/db/src/sql/__tests__/update.test.ts | 4 +--- packages/db/src/sql/insert.ts | 6 +----- packages/mint-docs/guides/db/queries.mdx | 3 ++- 4 files changed, 7 insertions(+), 12 deletions(-) diff --git a/packages/db/src/schema/table.ts b/packages/db/src/schema/table.ts index 6b3fc2202..b97386822 100644 --- a/packages/db/src/schema/table.ts +++ b/packages/db/src/schema/table.ts @@ -166,9 +166,9 @@ type ApiCreateInput = { * Excludes readOnly and primary key columns. All fields optional (partial update). */ type ApiUpdateInput = { - [K in ColumnKeysWhereNot & - ColumnKeysWhereNot & - string]?: InferColumnType | DbExpr; + [K in ColumnKeysWhereNot & ColumnKeysWhereNot & string]?: + | InferColumnType + | DbExpr; }; // --------------------------------------------------------------------------- diff --git a/packages/db/src/sql/__tests__/update.test.ts b/packages/db/src/sql/__tests__/update.test.ts index 2810907c8..34e2a08ef 100644 --- a/packages/db/src/sql/__tests__/update.test.ts +++ b/packages/db/src/sql/__tests__/update.test.ts @@ -141,9 +141,7 @@ describe('buildUpdate', () => { data: { stock: d.decrement(3) }, where: { id: 'p1' }, }); - expect(result.sql).toBe( - 'UPDATE "products" SET "stock" = "stock" - $1 WHERE "id" = $2', - ); + expect(result.sql).toBe('UPDATE "products" SET "stock" = "stock" - $1 WHERE "id" = $2'); expect(result.params).toEqual([3, 'p1']); }); diff --git a/packages/db/src/sql/insert.ts b/packages/db/src/sql/insert.ts index 2e78fddd7..377759204 100644 --- a/packages/db/src/sql/insert.ts +++ b/packages/db/src/sql/insert.ts @@ -109,11 +109,7 @@ export function buildInsert( params: [], }; const fragment = val.build(colRef); - const renumbered = renumberParamsWithDialect( - fragment.sql, - allParams.length, - dialect, - ); + const renumbered = renumberParamsWithDialect(fragment.sql, allParams.length, dialect); allParams.push(...fragment.params); return `"${snakeCol}" = ${renumbered}`; } diff --git a/packages/mint-docs/guides/db/queries.mdx b/packages/mint-docs/guides/db/queries.mdx index d13343965..3da3169c1 100644 --- a/packages/mint-docs/guides/db/queries.mdx +++ b/packages/mint-docs/guides/db/queries.mdx @@ -240,7 +240,8 @@ await db.urls.update({ Expressions work in `update`, `updateMany`, and the `update` path of `upsert`. They compose with the `sql` tagged template — the `col` parameter in `d.expr()` is the column reference, automatically converted to snake_case. - Expressions override `autoUpdate()` columns. If you pass `d.increment()` on an `autoUpdate()` column like `updatedAt`, your expression is used instead of the automatic `NOW()`. + Expressions override `autoUpdate()` columns. If you pass `d.increment()` on an `autoUpdate()` + column like `updatedAt`, your expression is used instead of the automatic `NOW()`. ### Upsert