feat: MySQL, SQL Server and SQLite dialects; writes without RETURNING; one null order on every database

This commit is contained in:
2026-11-02 01:45:55 +01:00
parent 6801f257fb
commit 3c78bd97c1
43 changed files with 2776 additions and 25 deletions
+2
View File
@@ -37,6 +37,8 @@ export interface CatalogFunction {
name: string;
args: { name: string; type: LogicalType; nativeType: string; hasDefault: boolean }[];
returnsSet: boolean;
/** The columns of each returned row, when the dialect can read them. */
columns?: CatalogColumn[];
}
export interface Catalog {
+4
View File
@@ -15,6 +15,8 @@ export interface Capabilities {
estimatedCount: boolean;
/** Row security enforced by the database for the session's role. */
rowSecurity: boolean;
/** Database functions can be called as procedures. */
procedures: boolean;
}
export interface Dialect {
@@ -25,6 +27,8 @@ export interface Dialect {
introspect(scope: { schemas: string[]; tables: string[] }): Promise<Catalog>;
/** Runs fn in one transaction with a statement timeout; read-only when asked. */
transaction<T>(fn: (tx: Tx) => Promise<T>, options: { timeoutMs: number; readOnly: boolean }): Promise<T>;
/** The JSON value of a statement's `r` column as a JavaScript value: drivers that parse JSON return it as is, others parse text. */
decode(value: unknown): unknown;
/** A database error the client caused, as a RestError; null for anything else, which stays a 500. */
mapError(err: unknown): RestError | null;
/** The planner's row estimate for a count query, when estimatedCount is supported. */
+1 -6
View File
@@ -6,11 +6,6 @@ import type { Filter, ReadPlan } from './plan.js';
import type { Resource } from './resources.js';
import { typed } from './grammar/values.js';
function decode(row: Record<string, unknown>): unknown {
const r = row.r;
return typeof r === 'string' ? JSON.parse(r) : r;
}
async function total(db: Dialect, tx: Tx, plan: ReadPlan, scope: (r: Resource) => Filter[]): Promise<number | null> {
if (plan.count === 'none') return null;
const stmt = db.renderer.count(plan, db.renderer.context(scope));
@@ -42,7 +37,7 @@ export async function readRows(db: Dialect, tx: Tx, resource: Resource, request:
plan.count = 'none';
}
const stmt = db.renderer.read(plan, db.renderer.context(scope));
const rows = (await tx.query(stmt.sql, stmt.params)).map(decode);
const rows = (await tx.query(stmt.sql, stmt.params)).map((row) => db.decode(row.r));
if (id !== undefined) {
if (rows.length === 0) throw notFound();
return { status: 200, headers: {}, body: rows[0] };
+1 -1
View File
@@ -67,7 +67,7 @@ export function createREST<C = unknown>(options: RestOptions<C>): Rest<C> {
const args = procedureArgs(call.procedure.fn, call.values, request.body);
return inTransaction(request, false, async (tx) => {
const stmt = db.renderer.call(call.procedure.fn, args, db.renderer.context(scope));
const rows = (await tx.query(stmt.sql, stmt.params)).map((r) => (typeof r.r === 'string' ? JSON.parse(r.r) : r.r));
const rows = (await tx.query(stmt.sql, stmt.params)).map((row) => db.decode(row.r));
return { status: 200, headers: {}, body: call.procedure.fn.returnsSet ? rows : rows[0] ?? null };
});
}
+13
View File
@@ -22,6 +22,19 @@ export class SqlRenderer extends SqlReader {
return ctx.params.finalize(sql, (i) => this.placeholder(i));
}
/** For databases without RETURNING: the keys a write will reach, locked until the transaction ends. */
selectKeys(resource: Resource, where: Filter[], ctx: RenderContext): Statement {
const alias = ctx.alias();
const conditions = this.filters(resource, alias, where, ctx);
const sql = `SELECT ${resource.key.map((k) => `${this.column(alias, k)} AS ${this.quote(k)}`).join(', ')} FROM ${this.table(resource.schema, resource.table)} ${alias}${conditions.length ? ` WHERE ${conditions.join(' AND ')}` : ''} FOR UPDATE`;
return ctx.params.finalize(sql, (i) => this.placeholder(i));
}
/** For databases without RETURNING: the first generated key of the last insert, as `id`. */
lastInsertId(): string {
return 'SELECT LAST_INSERT_ID() AS id';
}
writeTarget(resource: Resource, alias: string): string {
return `${this.table(resource.schema, resource.table)} AS ${alias}`;
}
+31 -5
View File
@@ -47,6 +47,34 @@ async function readBack(c: WriteContext, keys: Record<string, unknown>[], status
return { status, headers: {}, body: read.body };
}
/**
* Runs an update or delete and returns the keys it reached. A database
* without RETURNING locks and reads the keys first, then writes by key, which
* also keeps it from reading the table it is changing.
*/
async function writeByKeys(c: WriteContext, where: Filter[], write: (where: Filter[]) => { sql: string; params: unknown[] }): Promise<Record<string, unknown>[]> {
if (c.db.capabilities.returning) {
const stmt = write(where);
return c.tx.query(stmt.sql, stmt.params);
}
const sel = c.db.renderer.selectKeys(c.resource, where, c.db.renderer.context(c.scope));
const keys = await c.tx.query(sel.sql, sel.params);
if (keys.length === 0) return keys;
const stmt = write([keysFilter(c.resource, keys)]);
await c.tx.query(stmt.sql, stmt.params);
return keys;
}
/** The keys of inserted rows: returned by the database, given in the body, or generated in sequence. */
async function insertedKeys(c: WriteContext, returned: Record<string, unknown>[], columns: string[], values: unknown[][]): Promise<Record<string, unknown>[]> {
if (c.db.capabilities.returning) return returned;
const key = c.resource.key;
if (key.every((k) => columns.includes(k))) return values.map((row) => Object.fromEntries(key.map((k) => [k, row[columns.indexOf(k)]])));
if (key.length !== 1) return [];
const [first] = await c.tx.query(c.db.renderer.lastInsertId(), []);
return values.map((_, i) => ({ [key[0]]: Number(first.id) + i }));
}
/** The filters a PATCH or DELETE applies: the id, or the query's filters on a bulk-enabled resource. */
function target(c: WriteContext, id: string | undefined): Filter[] {
if (id !== undefined) return [keyFilter(c.resource, id)];
@@ -66,7 +94,7 @@ export async function create(c: WriteContext): Promise<RestResponse> {
return scope.has(col) ? scope.get(col) : row.get(col);
}));
const stmt = c.db.renderer.insert(c.resource, columns, values, c.db.renderer.context(c.scope));
const keys = await c.tx.query(stmt.sql, stmt.params);
const keys = await insertedKeys(c, await c.tx.query(stmt.sql, stmt.params), columns, values);
return wantsRows(c.request) ? readBack(c, keys, 201) : { status: 201, headers: {}, body: undefined };
}
@@ -76,8 +104,7 @@ export async function update(c: WriteContext, id: string | undefined): Promise<R
const scope = pinned(c);
for (const [col, v] of row) if (scope.has(col) && v !== scope.get(col)) throw new RestError(403, 'out_of_scope', col);
const where = [...c.scope(c.resource), ...target(c, id)];
const stmt = c.db.renderer.update(c.resource, [...row], where, c.db.renderer.context(c.scope));
const keys = await c.tx.query(stmt.sql, stmt.params);
const keys = await writeByKeys(c, where, (w) => c.db.renderer.update(c.resource, [...row], w, c.db.renderer.context(c.scope)));
if (id !== undefined && keys.length === 0) throw notFound();
if (!wantsRows(c.request)) return { status: 204, headers: {}, body: undefined };
const back = await readBack(c, keys, 200);
@@ -90,8 +117,7 @@ export async function remove(c: WriteContext, id: string | undefined): Promise<R
const before = wantsRows(c.request)
? await readRows(c.db, c.tx, c.resource, { ...c.request, query: selectOnly(c.request, c.limits.maxPage) }, c.limits, c.scope, undefined, where)
: null;
const stmt = c.db.renderer.delete(c.resource, where, c.db.renderer.context(c.scope));
const keys = await c.tx.query(stmt.sql, stmt.params);
const keys = await writeByKeys(c, where, (w) => c.db.renderer.delete(c.resource, w, c.db.renderer.context(c.scope)));
if (id !== undefined && keys.length === 0) throw notFound();
if (!before) return { status: 204, headers: {}, body: undefined };
return { status: 200, headers: {}, body: id !== undefined ? (before.body as unknown[])[0] : before.body };
+2 -1
View File
@@ -6,7 +6,8 @@ export function fakeDialect(rows = [{ id: 1, name: 'one' }]) {
return {
statements,
name: 'fake',
capabilities: { returning: true, estimatedCount: false, rowSecurity: false },
capabilities: { returning: true, estimatedCount: false, rowSecurity: false, procedures: false },
decode: (v) => v,
renderer: new SqlRenderer(),
async introspect() {
const col = (name, type) => ({ name, type, nativeType: type, nullable: false, generated: false });