feat: MySQL, SQL Server and SQLite dialects; writes without RETURNING; one null order on every database
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"name": "@crudy/restloom-mysql",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "MySQL dialect for restloom",
|
||||
"keywords": [
|
||||
"restloom",
|
||||
"mysql",
|
||||
"rest",
|
||||
"api"
|
||||
],
|
||||
"author": "Marc Villain",
|
||||
"homepage": "https://restloom.crudy.fr",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/crudylabs/restloom.git",
|
||||
"directory": "packages/mysql"
|
||||
},
|
||||
"license": "Apache-2.0",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"LICENSE",
|
||||
"README.md"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p .",
|
||||
"prepack": "npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@crudy/restloom": "^0.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"mysql2": "^3.11.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.0.0",
|
||||
"mysql2": "^3.24.5",
|
||||
"typescript": "^5.9.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { RestError, type Dialect, type SqlRenderer } from '@crudy/restloom';
|
||||
import type { Pool } from 'mysql2/promise';
|
||||
import { introspect } from './introspect.js';
|
||||
import { MysqlRenderer } from './renderer.js';
|
||||
|
||||
export { MysqlRenderer } from './renderer.js';
|
||||
|
||||
const CODES: Record<number, [number, string]> = {
|
||||
1062: [409, 'duplicate'],
|
||||
1452: [409, 'reference_missing'],
|
||||
1451: [409, 'still_referenced'],
|
||||
1048: [400, 'value_required'],
|
||||
1364: [400, 'value_required'],
|
||||
3819: [400, 'value_rejected'],
|
||||
1292: [400, 'invalid_value'],
|
||||
1264: [400, 'invalid_value'],
|
||||
1366: [400, 'invalid_value'],
|
||||
1406: [400, 'invalid_value'],
|
||||
1142: [403, 'forbidden'],
|
||||
3024: [503, 'timeout'],
|
||||
};
|
||||
|
||||
export function mapError(err: unknown): RestError | null {
|
||||
const errno = (err as { errno?: unknown })?.errno;
|
||||
const hit = typeof errno === 'number' ? CODES[errno] : undefined;
|
||||
return hit ? new RestError(hit[0], hit[1]) : null;
|
||||
}
|
||||
|
||||
export interface MysqlOptions {
|
||||
renderer?: SqlRenderer;
|
||||
}
|
||||
|
||||
/**
|
||||
* The MySQL 8 dialect over a mysql2/promise Pool. Each request is one
|
||||
* transaction on one connection, in UTC, with MAX_EXECUTION_TIME for reads.
|
||||
* Text compares with each column's collation, so with a case-insensitive
|
||||
* collation `eq` ignores case.
|
||||
*/
|
||||
export function mysql(pool: Pool, options: MysqlOptions = {}): Dialect {
|
||||
return {
|
||||
name: 'mysql',
|
||||
capabilities: { returning: false, estimatedCount: false, rowSecurity: false, procedures: true },
|
||||
renderer: options.renderer ?? new MysqlRenderer(),
|
||||
decode: (value) => value,
|
||||
introspect: (scope) => introspect(pool, scope),
|
||||
async transaction(fn, { timeoutMs, readOnly }) {
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
await conn.query(`SET SESSION time_zone = '+00:00', SESSION group_concat_max_len = 1073741824, SESSION MAX_EXECUTION_TIME = ${Math.max(1, Math.floor(timeoutMs))}`);
|
||||
await conn.query(readOnly ? 'START TRANSACTION READ ONLY' : 'START TRANSACTION');
|
||||
const out = await fn({
|
||||
async query(sql, params) {
|
||||
const [rows] = await conn.query(sql, params);
|
||||
return Array.isArray(rows) ? (rows as Record<string, unknown>[]) : [];
|
||||
},
|
||||
});
|
||||
await conn.query('COMMIT');
|
||||
return out;
|
||||
} catch (err) {
|
||||
await conn.query('ROLLBACK').catch(() => undefined);
|
||||
throw err;
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
},
|
||||
mapError,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { Catalog, CatalogTable } from '@crudy/restloom';
|
||||
import type { Pool } from 'mysql2/promise';
|
||||
import { logical } from './types.js';
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
const IN_SCOPE = '(t.TABLE_SCHEMA IN (?) OR CONCAT(t.TABLE_SCHEMA, \'.\', t.TABLE_NAME) IN (?))';
|
||||
|
||||
/** Reads tables, views, columns, keys and functions from information_schema. MySQL's schemas are its databases. */
|
||||
export async function introspect(pool: Pool, scope: { schemas: string[]; tables: string[] }): Promise<Catalog> {
|
||||
const schemas = scope.schemas.length ? scope.schemas : [''];
|
||||
const tablesIn = scope.tables.length ? scope.tables : [''];
|
||||
const q = async (sql: string, params: unknown[]) => (await pool.query(sql, params))[0] as Row[];
|
||||
const [tables, columns, keys, params, functions] = await Promise.all([
|
||||
q(`SELECT TABLE_SCHEMA AS s, TABLE_NAME AS n, TABLE_TYPE AS k FROM information_schema.TABLES t WHERE ${IN_SCOPE}`, [schemas, tablesIn]),
|
||||
q(`SELECT c.TABLE_SCHEMA AS s, c.TABLE_NAME AS n, c.COLUMN_NAME AS name, c.DATA_TYPE AS dt, c.COLUMN_TYPE AS ct, c.IS_NULLABLE AS nullable, c.EXTRA AS extra
|
||||
FROM information_schema.COLUMNS c JOIN information_schema.TABLES t ON t.TABLE_SCHEMA = c.TABLE_SCHEMA AND t.TABLE_NAME = c.TABLE_NAME
|
||||
WHERE ${IN_SCOPE} ORDER BY c.TABLE_SCHEMA, c.TABLE_NAME, c.ORDINAL_POSITION`, [schemas, tablesIn]),
|
||||
q(`SELECT k.TABLE_SCHEMA AS s, k.TABLE_NAME AS n, k.CONSTRAINT_NAME AS c, k.COLUMN_NAME AS col,
|
||||
k.REFERENCED_TABLE_SCHEMA AS rs, k.REFERENCED_TABLE_NAME AS rt, k.REFERENCED_COLUMN_NAME AS rc
|
||||
FROM information_schema.KEY_COLUMN_USAGE k JOIN information_schema.TABLES t ON t.TABLE_SCHEMA = k.TABLE_SCHEMA AND t.TABLE_NAME = k.TABLE_NAME
|
||||
WHERE ${IN_SCOPE} AND (k.CONSTRAINT_NAME = 'PRIMARY' OR k.REFERENCED_TABLE_NAME IS NOT NULL)
|
||||
ORDER BY k.CONSTRAINT_NAME, k.ORDINAL_POSITION`, [schemas, tablesIn]),
|
||||
q(`SELECT SPECIFIC_SCHEMA AS s, SPECIFIC_NAME AS n, PARAMETER_NAME AS name, DATA_TYPE AS dt, DTD_IDENTIFIER AS ct
|
||||
FROM information_schema.PARAMETERS WHERE SPECIFIC_SCHEMA IN (?) AND ORDINAL_POSITION > 0 ORDER BY ORDINAL_POSITION`, [schemas]),
|
||||
q(`SELECT ROUTINE_SCHEMA AS s, ROUTINE_NAME AS n FROM information_schema.ROUTINES WHERE ROUTINE_SCHEMA IN (?) AND ROUTINE_TYPE = 'FUNCTION'`, [schemas]),
|
||||
]);
|
||||
const byName = new Map<string, CatalogTable>();
|
||||
for (const t of tables) {
|
||||
byName.set(`${t.s}.${t.n}`, { schema: t.s as string, name: t.n as string, kind: t.k === 'VIEW' ? 'view' : 'table', columns: [], primaryKey: [], foreignKeys: [] });
|
||||
}
|
||||
for (const c of columns) {
|
||||
byName.get(`${c.s}.${c.n}`)?.columns.push({
|
||||
name: c.name as string, nativeType: c.ct as string, type: logical(c.dt as string, c.ct as string),
|
||||
nullable: c.nullable === 'YES', generated: /GENERATED/i.test(c.extra as string),
|
||||
});
|
||||
}
|
||||
const fks = new Map<string, { table: CatalogTable; columns: string[]; targetColumns: string[]; target: { schema: string; table: string } }>();
|
||||
for (const k of keys) {
|
||||
const table = byName.get(`${k.s}.${k.n}`);
|
||||
if (!table) continue;
|
||||
if (k.c === 'PRIMARY') { table.primaryKey.push(k.col as string); continue; }
|
||||
const id = `${k.s}.${k.n}.${k.c}`;
|
||||
const fk = fks.get(id) ?? { table, columns: [], targetColumns: [], target: { schema: k.rs as string, table: k.rt as string } };
|
||||
fk.columns.push(k.col as string);
|
||||
fk.targetColumns.push(k.rc as string);
|
||||
fks.set(id, fk);
|
||||
}
|
||||
for (const { table, ...fk } of fks.values()) table.foreignKeys.push(fk);
|
||||
return {
|
||||
tables: [...byName.values()],
|
||||
functions: functions.map((f) => ({
|
||||
schema: f.s as string, name: f.n as string, returnsSet: false,
|
||||
args: params.filter((p) => p.s === f.s && p.n === f.n).map((p) => ({
|
||||
name: p.name as string, type: logical(p.dt as string, p.ct as string), nativeType: p.ct as string, hasDefault: false,
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { SqlRenderer, type CatalogColumn, type CatalogFunction, type RenderContext, type Statement } from '@crudy/restloom';
|
||||
|
||||
/**
|
||||
* MySQL 8: backquoted names, positional placeholders, no RETURNING. Arrays are
|
||||
* built with GROUP_CONCAT, which keeps an order where JSON_ARRAYAGG does not.
|
||||
*/
|
||||
export class MysqlRenderer extends SqlRenderer {
|
||||
quote(identifier: string): string {
|
||||
return `\`${identifier.replace(/`/g, '``')}\``;
|
||||
}
|
||||
|
||||
literal(text: string): string {
|
||||
return `'${text.replace(/\\/g, '\\\\').replace(/'/g, "''")}'`;
|
||||
}
|
||||
|
||||
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(CAST(CONCAT('[', GROUP_CONCAT(${rowExpr} ORDER BY ${orderExpr} SEPARATOR ','), ']') AS JSON), JSON_ARRAY())`;
|
||||
}
|
||||
|
||||
nested(expr: string): string {
|
||||
return `CAST(${expr} AS JSON)`;
|
||||
}
|
||||
|
||||
jsonValue(expr: string, column: CatalogColumn): string {
|
||||
if (column.type === 'boolean') return `IF(${expr} IS NULL, NULL, CAST(IF(${expr}, 'true', 'false') AS JSON))`;
|
||||
if (column.type === 'timestamp') return `DATE_FORMAT(${expr}, '%Y-%m-%dT%H:%i:%s${/^timestamp/i.test(column.nativeType) ? '+00:00' : ''}')`;
|
||||
return expr;
|
||||
}
|
||||
|
||||
like(expr: string, pattern: string, insensitive: boolean): string {
|
||||
return insensitive
|
||||
? `LOWER(${expr}) LIKE LOWER(${pattern}) ESCAPE '\\\\'`
|
||||
: `${expr} COLLATE utf8mb4_bin LIKE ${pattern} ESCAPE '\\\\'`;
|
||||
}
|
||||
|
||||
isBoolean(expr: string, value: boolean): string {
|
||||
return `${expr} = ${value ? 1 : 0}`;
|
||||
}
|
||||
|
||||
/** MySQL sorts nulls first ascending; this keeps the Postgres order: last ascending, first descending. */
|
||||
orderBy(expr: string, desc: boolean, nulls?: 'first' | 'last'): string {
|
||||
const first = (nulls ?? (desc ? 'first' : 'last')) === 'first';
|
||||
return `(${expr} IS NULL) ${first ? 'DESC' : 'ASC'}, ${expr} ${desc ? 'DESC' : 'ASC'}`;
|
||||
}
|
||||
|
||||
page(limit: number | undefined, offset: number | undefined): string {
|
||||
return limit === undefined && !offset ? '' : ` LIMIT ${limit ?? '18446744073709551615'}${offset ? ` OFFSET ${offset}` : ''}`;
|
||||
}
|
||||
|
||||
returning(): string {
|
||||
return '';
|
||||
}
|
||||
|
||||
call(fn: CatalogFunction, args: [string, unknown][], ctx: RenderContext): Statement {
|
||||
const byName = new Map(args);
|
||||
const call = `${this.table(fn.schema, fn.name)}(${fn.args.map((a) => ctx.params.add(byName.get(a.name))).join(', ')})`;
|
||||
return ctx.params.finalize(`SELECT JSON_EXTRACT(JSON_ARRAY(${call}), '$[0]') AS r`, (i) => this.placeholder(i));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { LogicalType } from '@crudy/restloom';
|
||||
|
||||
/** A MySQL column type as a logical type. TINYINT(1) is MySQL's boolean. */
|
||||
export function logical(dataType: string, columnType: string): LogicalType {
|
||||
const t = dataType.toLowerCase();
|
||||
if (columnType.toLowerCase().startsWith('tinyint(1)') || t === 'boolean') return 'boolean';
|
||||
if (['tinyint', 'smallint', 'mediumint', 'int', 'integer', 'bigint', 'year'].includes(t)) return 'integer';
|
||||
if (['decimal', 'numeric', 'float', 'double', 'real'].includes(t)) return 'number';
|
||||
if (['char', 'varchar', 'tinytext', 'text', 'mediumtext', 'longtext', 'enum', 'set'].includes(t)) return 'text';
|
||||
if (t === 'date') return 'date';
|
||||
if (t === 'datetime' || t === 'timestamp') return 'timestamp';
|
||||
if (t === 'time') return 'time';
|
||||
if (t === 'json') return 'json';
|
||||
if (['binary', 'varbinary', 'tinyblob', 'blob', 'mediumblob', 'longblob', 'bit'].includes(t)) return 'binary';
|
||||
return 'other';
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": { "outDir": "dist", "rootDir": "src" },
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user