feat: MySQL, SQL Server and SQLite dialects; writes without RETURNING; one null order on every database
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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] };
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user