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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/db-update-expressions.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 12 additions & 0 deletions packages/db/src/d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -88,6 +91,12 @@ export const d: {
): RelationDef<TTarget, 'many'>;
many<TTarget extends TableDef<ColumnRecord>>(target: () => TTarget): ManyRelationDef<TTarget>;
};
/** 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<TTable extends TableDef<ColumnRecord>>(table: TTable): ModelDef<TTable, {}>;
model<TTable extends TableDef<ColumnRecord>, TRelations extends Record<string, RelationDef>>(
Expand Down Expand Up @@ -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: {
Expand Down
6 changes: 6 additions & 0 deletions packages/db/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
21 changes: 21 additions & 0 deletions packages/db/src/query/__tests__/crud-unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
getOrThrow,
update,
updateMany,
upsert,
} from '../crud';
import type { QueryFn } from '../executor';

Expand Down Expand Up @@ -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 <T>(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', () => {
Expand Down
42 changes: 30 additions & 12 deletions packages/db/src/query/crud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -370,16 +371,22 @@ export async function update<T>(
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(). 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])];
Expand Down Expand Up @@ -422,13 +429,19 @@ 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() (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])];
Expand Down Expand Up @@ -489,12 +502,17 @@ export async function upsert<T>(
}),
);

// 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) {
filteredUpdate[col] = 'now';
if (!(col in filteredUpdate)) {
filteredUpdate[col] = 'now';
}
}

const allNowColumns = [...new Set([...nowColumns, ...autoUpdateCols])];
Expand Down
97 changes: 97 additions & 0 deletions packages/db/src/schema/__tests__/update-expr.test-d.ts
Original file line number Diff line number Diff line change
@@ -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<Update['clickCount']>;
type _t1 = Expect<Extends<DbExpr, ClickCountType>>;
type _t2 = Expect<Extends<number, ClickCountType>>;
});
});

// ---------------------------------------------------------------------------
// $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<UpdateInput['clickCount']>;
type _t1 = Expect<Extends<DbExpr, ClickCountType>>;
});
});

// ---------------------------------------------------------------------------
// 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;
});
});
9 changes: 5 additions & 4 deletions packages/db/src/schema/table.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { ColumnBuilder, ColumnMetadata, InferColumnType } from './column';
import type { RelationDef } from './relation';
import type { DbExpr } from '../sql/expr';

// ---------------------------------------------------------------------------
// Index Definition
Expand Down Expand Up @@ -119,7 +120,7 @@ type Insert<T extends ColumnRecord> = {
* Primary key excluded (you don't update a PK).
*/
type Update<T extends ColumnRecord> = {
[K in ColumnKeysWhereNot<T, 'primary'>]?: InferColumnType<T[K]>;
[K in ColumnKeysWhereNot<T, 'primary'>]?: InferColumnType<T[K]> | DbExpr;
};

/**
Expand Down Expand Up @@ -165,9 +166,9 @@ type ApiCreateInput<T extends ColumnRecord> = {
* Excludes readOnly and primary key columns. All fields optional (partial update).
*/
type ApiUpdateInput<T extends ColumnRecord> = {
[K in ColumnKeysWhereNot<T, 'isReadOnly'> &
ColumnKeysWhereNot<T, 'primary'> &
string]?: InferColumnType<T[K]>;
[K in ColumnKeysWhereNot<T, 'isReadOnly'> & ColumnKeysWhereNot<T, 'primary'> & string]?:
| InferColumnType<T[K]>
| DbExpr;
};

// ---------------------------------------------------------------------------
Expand Down
100 changes: 100 additions & 0 deletions packages/db/src/sql/__tests__/expr.test.ts
Original file line number Diff line number Diff line change
@@ -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]);
});
});
});
Loading
Loading