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
+69
View File
@@ -0,0 +1,69 @@
import { RestError, type Dialect, type SqlRenderer } from '@crudy/restloom';
import type { Database } from 'better-sqlite3';
import { introspect } from './introspect.js';
import { SqliteRenderer } from './renderer.js';
export { SqliteRenderer } from './renderer.js';
const CODES: Record<string, [number, string]> = {
SQLITE_CONSTRAINT_UNIQUE: [409, 'duplicate'],
SQLITE_CONSTRAINT_PRIMARYKEY: [409, 'duplicate'],
SQLITE_CONSTRAINT_FOREIGNKEY: [409, 'reference_missing'],
SQLITE_CONSTRAINT_NOTNULL: [400, 'value_required'],
SQLITE_CONSTRAINT_CHECK: [400, 'value_rejected'],
SQLITE_MISMATCH: [400, 'invalid_value'],
};
export function mapError(err: unknown): RestError | null {
const code = (err as { code?: unknown })?.code;
const hit = typeof code === 'string' ? CODES[code] : undefined;
return hit ? new RestError(hit[0], hit[1]) : null;
}
const bindable = (v: unknown) => (v === true ? 1 : v === false ? 0 : v);
export interface SqliteOptions {
renderer?: SqlRenderer;
}
/**
* The SQLite dialect over a better-sqlite3 Database. It turns on foreign keys
* and case-sensitive LIKE for the connection. One connection runs one
* transaction at a time, so requests are queued. SQLite cannot stop a running
* statement, so `limits.timeoutMs` does not apply, and it has no functions to
* call as procedures.
*/
export function sqlite(db: Database, options: SqliteOptions = {}): Dialect {
db.pragma('foreign_keys = ON');
db.pragma('case_sensitive_like = ON');
let queue: Promise<unknown> = Promise.resolve();
return {
name: 'sqlite',
capabilities: { returning: true, estimatedCount: false, rowSecurity: false, procedures: false },
renderer: options.renderer ?? new SqliteRenderer(),
decode: (value) => (typeof value === 'string' ? JSON.parse(value) : value),
introspect: async (scope) => introspect(db, scope),
transaction(fn) {
const run = queue.then(async () => {
db.exec('BEGIN');
try {
const out = await fn({
async query(sql, params) {
const stmt = db.prepare(sql);
const bound = params.map(bindable);
return (stmt.reader ? stmt.all(...bound) : (stmt.run(...bound), [])) as Record<string, unknown>[];
},
});
db.exec('COMMIT');
return out;
} catch (err) {
if (db.inTransaction) db.exec('ROLLBACK');
throw err;
}
});
queue = run.catch(() => undefined);
return run;
},
mapError,
};
}
+42
View File
@@ -0,0 +1,42 @@
import type { Catalog, CatalogTable } from '@crudy/restloom';
import type { Database } from 'better-sqlite3';
import { logical } from './types.js';
type Row = Record<string, unknown>;
/** Reads tables and views of the main database or of attached ones; SQLite's schemas are its attached databases. */
export function introspect(db: Database, scope: { schemas: string[]; tables: string[] }): Catalog {
const schemas = new Set([...scope.schemas, ...scope.tables.map((t) => t.split('.')[0])]);
const attached = new Set((db.prepare('PRAGMA database_list').all() as Row[]).map((r) => r.name as string));
const tables: CatalogTable[] = [];
for (const schema of schemas) {
if (!attached.has(schema)) continue;
const q = (s: string) => `"${s.replace(/"/g, '""')}"`;
const rows = db.prepare(`SELECT name, type FROM ${q(schema)}.sqlite_schema WHERE type IN ('table', 'view') AND name NOT LIKE 'sqlite_%'`).all() as Row[];
for (const r of rows) {
const name = r.name as string;
if (!scope.schemas.includes(schema) && !scope.tables.includes(`${schema}.${name}`)) continue;
const cols = db.prepare(`PRAGMA ${q(schema)}.table_xinfo(${q(name)})`).all() as Row[];
const fks = db.prepare(`PRAGMA ${q(schema)}.foreign_key_list(${q(name)})`).all() as Row[];
const byId = new Map<number, Row[]>();
for (const f of fks) byId.set(f.id as number, [...(byId.get(f.id as number) ?? []), f]);
tables.push({
schema, name, kind: r.type === 'view' ? 'view' : 'table',
columns: cols.filter((c) => c.hidden !== 1).map((c) => ({
name: c.name as string, nativeType: c.type as string, type: logical(c.type as string),
nullable: !c.notnull, generated: c.hidden === 2 || c.hidden === 3,
})),
primaryKey: cols.filter((c) => (c.pk as number) > 0).sort((a, b) => (a.pk as number) - (b.pk as number)).map((c) => c.name as string),
foreignKeys: [...byId.values()].map((parts) => {
const ordered = parts.sort((a, b) => (a.seq as number) - (b.seq as number));
return {
columns: ordered.map((p) => p.from as string),
target: { schema, table: ordered[0].table as string },
targetColumns: ordered.map((p) => p.to as string),
};
}),
});
}
}
return { tables, functions: [] };
}
+43
View File
@@ -0,0 +1,43 @@
import { SqlRenderer, type CatalogColumn } from '@crudy/restloom';
/** SQLite: JSON through json_object and json_group_array, booleans as 0 and 1, positional placeholders. */
export class SqliteRenderer extends SqlRenderer {
placeholder(_index: number): string {
return '?';
}
jsonObject(pairs: [string, string][]): string {
return `json_object(${pairs.map(([k, v]) => `${this.literal(k)}, ${v}`).join(', ')})`;
}
jsonArray(rowExpr: string, orderExpr: string): string {
return `COALESCE(json_group_array(json(${rowExpr}) ORDER BY ${orderExpr}), json('[]'))`;
}
nested(expr: string): string {
return `json(${expr})`;
}
jsonValue(expr: string, column: CatalogColumn): string {
if (column.type === 'boolean') return `CASE WHEN ${expr} IS NULL THEN NULL WHEN ${expr} THEN json('true') ELSE json('false') END`;
if (column.type === 'json') return `json(${expr})`;
return expr;
}
like(expr: string, pattern: string, insensitive: boolean): string {
return insensitive ? `lower(${expr}) LIKE lower(${pattern}) ESCAPE '\\'` : `${expr} LIKE ${pattern} ESCAPE '\\'`;
}
isBoolean(expr: string, value: boolean): string {
return `${expr} = ${value ? 1 : 0}`;
}
/** SQLite sorts nulls first ascending; this keeps the Postgres order: last ascending, first descending. */
orderBy(expr: string, desc: boolean, nulls?: 'first' | 'last'): string {
return super.orderBy(expr, desc, nulls ?? (desc ? 'first' : 'last'));
}
page(limit: number | undefined, offset: number | undefined): string {
return limit === undefined && !offset ? '' : ` LIMIT ${limit ?? -1}${offset ? ` OFFSET ${offset}` : ''}`;
}
}
+17
View File
@@ -0,0 +1,17 @@
import type { LogicalType } from '@crudy/restloom';
/** A declared SQLite column type as a logical type, by the same keyword rules SQLite uses for affinity, refined for common names. */
export function logical(declared: string): LogicalType {
const t = declared.toUpperCase();
if (t === 'BOOLEAN' || t === 'BOOL') return 'boolean';
if (t === 'UUID') return 'uuid';
if (t === 'DATE') return 'date';
if (t.includes('DATETIME') || t.includes('TIMESTAMP')) return 'timestamp';
if (t === 'TIME') return 'time';
if (t === 'JSON' || t === 'JSONB') return 'json';
if (t.includes('INT')) return 'integer';
if (t.includes('CHAR') || t.includes('CLOB') || t.includes('TEXT')) return 'text';
if (t.includes('BLOB') || t === '') return t === '' ? 'other' : 'binary';
if (t.includes('REAL') || t.includes('FLOA') || t.includes('DOUB') || t.includes('NUMERIC') || t.includes('DECIMAL')) return 'number';
return 'other';
}