feat: MySQL, SQL Server and SQLite dialects; writes without RETURNING; one null order on every database
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "@crudy/restloom-mssql",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "SQL Server dialect for restloom",
|
||||
"keywords": [
|
||||
"restloom",
|
||||
"mssql",
|
||||
"sql-server",
|
||||
"rest",
|
||||
"api"
|
||||
],
|
||||
"author": "Marc Villain",
|
||||
"homepage": "https://restloom.crudy.fr",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/crudylabs/restloom.git",
|
||||
"directory": "packages/mssql"
|
||||
},
|
||||
"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": {
|
||||
"mssql": "^12.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/mssql": "^12.0.0",
|
||||
"mssql": "12.7.10",
|
||||
"typescript": "^5.9.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { RestError, type Dialect, type SqlRenderer } from '@crudy/restloom';
|
||||
import mssqlDriver, { type ConnectionPool } from 'mssql';
|
||||
import { introspect } from './introspect.js';
|
||||
import { MssqlRenderer } from './renderer.js';
|
||||
|
||||
export { MssqlRenderer } from './renderer.js';
|
||||
|
||||
const CODES: Record<number, [number, string]> = {
|
||||
2627: [409, 'duplicate'],
|
||||
2601: [409, 'duplicate'],
|
||||
515: [400, 'value_required'],
|
||||
245: [400, 'invalid_value'],
|
||||
241: [400, 'invalid_value'],
|
||||
242: [400, 'invalid_value'],
|
||||
8114: [400, 'invalid_value'],
|
||||
8115: [400, 'invalid_value'],
|
||||
2628: [400, 'invalid_value'],
|
||||
229: [403, 'forbidden'],
|
||||
230: [403, 'forbidden'],
|
||||
};
|
||||
|
||||
/** Error 547 covers both foreign keys and checks; its message tells which. */
|
||||
export function mapError(err: unknown): RestError | null {
|
||||
const e = err as { number?: unknown; code?: unknown; message?: unknown; originalError?: { info?: { number?: unknown; message?: unknown } } };
|
||||
if (e?.code === 'ETIMEOUT') return new RestError(503, 'timeout');
|
||||
const number = e?.number ?? e?.originalError?.info?.number;
|
||||
if (number === 547) {
|
||||
const text = String(e.message ?? e.originalError?.info?.message ?? '');
|
||||
return /CHECK constraint/i.test(text) ? new RestError(400, 'value_rejected') : new RestError(409, 'reference_missing');
|
||||
}
|
||||
const hit = typeof number === 'number' ? CODES[number] : undefined;
|
||||
return hit ? new RestError(hit[0], hit[1]) : null;
|
||||
}
|
||||
|
||||
export interface MssqlOptions {
|
||||
renderer?: SqlRenderer;
|
||||
}
|
||||
|
||||
/**
|
||||
* The SQL Server 2022 dialect over an mssql ConnectionPool. Each request is one
|
||||
* transaction; the statement timeout is the pool's `requestTimeout`, since the
|
||||
* driver sets it per pool. Row security is SQL Server's security policies,
|
||||
* which a `transaction` hook feeds through sp_set_session_context.
|
||||
*/
|
||||
export function mssql(pool: ConnectionPool, options: MssqlOptions = {}): Dialect {
|
||||
return {
|
||||
name: 'mssql',
|
||||
capabilities: { returning: true, estimatedCount: false, rowSecurity: true, procedures: true },
|
||||
renderer: options.renderer ?? new MssqlRenderer(),
|
||||
decode: (value) => (typeof value === 'string' ? JSON.parse(value) : value),
|
||||
introspect: (scope) => introspect(pool, scope),
|
||||
async transaction(fn) {
|
||||
const tx = new mssqlDriver.Transaction(pool);
|
||||
await tx.begin();
|
||||
try {
|
||||
const out = await fn({
|
||||
async query(sql, params) {
|
||||
const req = new mssqlDriver.Request(tx);
|
||||
params.forEach((v, i) => {
|
||||
// Whole numbers beyond 32 bits go as BigInt, or the driver overflows them as Int.
|
||||
if (typeof v === 'number' && Number.isInteger(v) && Math.abs(v) > 2147483647) req.input(`p${i + 1}`, mssqlDriver.BigInt, v);
|
||||
else req.input(`p${i + 1}`, v);
|
||||
});
|
||||
return ((await req.query(sql)).recordset ?? []) as Record<string, unknown>[];
|
||||
},
|
||||
});
|
||||
await tx.commit();
|
||||
return out;
|
||||
} catch (err) {
|
||||
await tx.rollback().catch(() => undefined);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
mapError,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { Catalog, CatalogTable } from '@crudy/restloom';
|
||||
import type { ConnectionPool } from 'mssql';
|
||||
import { logical } from './types.js';
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
const OBJECTS = `
|
||||
SELECT o.object_id AS id, SCHEMA_NAME(o.schema_id) AS s, o.name AS n, RTRIM(o.type) AS t
|
||||
FROM sys.objects o
|
||||
WHERE o.type IN ('U', 'V', 'FN', 'IF', 'TF')
|
||||
AND (SCHEMA_NAME(o.schema_id) IN (SELECT value FROM STRING_SPLIT(@schemas, ','))
|
||||
OR (o.type IN ('U', 'V') AND SCHEMA_NAME(o.schema_id) + '.' + o.name IN (SELECT value FROM STRING_SPLIT(@tables, ','))))`;
|
||||
|
||||
const COLUMNS = `
|
||||
SELECT c.object_id AS id, c.name, TYPE_NAME(c.user_type_id) AS type, c.is_nullable AS nullable,
|
||||
CAST(CASE WHEN c.is_computed = 1 OR c.is_identity = 1 THEN 1 ELSE 0 END AS bit) AS generated
|
||||
FROM sys.columns c WHERE c.object_id IN (SELECT value FROM STRING_SPLIT(@ids, ','))
|
||||
ORDER BY c.object_id, c.column_id`;
|
||||
|
||||
const PRIMARY_KEYS = `
|
||||
SELECT i.object_id AS id, c.name FROM sys.indexes i
|
||||
JOIN sys.index_columns ic ON ic.object_id = i.object_id AND ic.index_id = i.index_id
|
||||
JOIN sys.columns c ON c.object_id = ic.object_id AND c.column_id = ic.column_id
|
||||
WHERE i.is_primary_key = 1 AND i.object_id IN (SELECT value FROM STRING_SPLIT(@ids, ','))
|
||||
ORDER BY i.object_id, ic.key_ordinal`;
|
||||
|
||||
const FOREIGN_KEYS = `
|
||||
SELECT fk.object_id AS fk, fk.parent_object_id AS id, pc.name AS col, SCHEMA_NAME(t.schema_id) AS ts, t.name AS tt, rc.name AS rcol
|
||||
FROM sys.foreign_keys fk
|
||||
JOIN sys.foreign_key_columns fkc ON fkc.constraint_object_id = fk.object_id
|
||||
JOIN sys.columns pc ON pc.object_id = fkc.parent_object_id AND pc.column_id = fkc.parent_column_id
|
||||
JOIN sys.columns rc ON rc.object_id = fkc.referenced_object_id AND rc.column_id = fkc.referenced_column_id
|
||||
JOIN sys.tables t ON t.object_id = fk.referenced_object_id
|
||||
WHERE fk.parent_object_id IN (SELECT value FROM STRING_SPLIT(@ids, ','))
|
||||
ORDER BY fk.object_id, fkc.constraint_column_id`;
|
||||
|
||||
const PARAMETERS = `
|
||||
SELECT p.object_id AS id, SUBSTRING(p.name, 2, 200) AS name, TYPE_NAME(p.user_type_id) AS type
|
||||
FROM sys.parameters p WHERE p.parameter_id > 0 AND p.object_id IN (SELECT value FROM STRING_SPLIT(@ids, ','))
|
||||
ORDER BY p.object_id, p.parameter_id`;
|
||||
|
||||
/** Reads tables, views and functions from the catalog views. T-SQL does not expose parameter defaults, so every argument is required. */
|
||||
export async function introspect(pool: ConnectionPool, scope: { schemas: string[]; tables: string[] }): Promise<Catalog> {
|
||||
const run = async (sql: string, params: Record<string, string>) => {
|
||||
const req = pool.request();
|
||||
for (const [k, v] of Object.entries(params)) req.input(k, v);
|
||||
return (await req.query(sql)).recordset as Row[];
|
||||
};
|
||||
const objects = await run(OBJECTS, { schemas: scope.schemas.join(','), tables: scope.tables.join(',') });
|
||||
const ids = { ids: objects.map((o) => String(o.id)).join(',') };
|
||||
const [columns, keys, fks, params] = await Promise.all([run(COLUMNS, ids), run(PRIMARY_KEYS, ids), run(FOREIGN_KEYS, ids), run(PARAMETERS, ids)]);
|
||||
const columnsOf = (id: unknown) => columns.filter((c) => c.id === id).map((c) => ({
|
||||
name: c.name as string, nativeType: c.type as string, type: logical(c.type as string), nullable: !!c.nullable, generated: !!c.generated,
|
||||
}));
|
||||
const tables = new Map<number, CatalogTable>();
|
||||
for (const o of objects) {
|
||||
if (o.t !== 'U' && o.t !== 'V') continue;
|
||||
tables.set(o.id as number, { schema: o.s as string, name: o.n as string, kind: o.t === 'V' ? 'view' : 'table', columns: [], primaryKey: [], foreignKeys: [] });
|
||||
}
|
||||
for (const [id, table] of tables) table.columns = columnsOf(id);
|
||||
for (const k of keys) tables.get(k.id as number)?.primaryKey.push(k.name as string);
|
||||
const byFk = new Map<number, Row[]>();
|
||||
for (const f of fks) byFk.set(f.fk as number, [...(byFk.get(f.fk as number) ?? []), f]);
|
||||
for (const parts of byFk.values()) {
|
||||
tables.get(parts[0].id as number)?.foreignKeys.push({
|
||||
columns: parts.map((p) => p.col as string), targetColumns: parts.map((p) => p.rcol as string),
|
||||
target: { schema: parts[0].ts as string, table: parts[0].tt as string },
|
||||
});
|
||||
}
|
||||
const functions = objects.filter((o) => o.t === 'FN' || o.t === 'IF' || o.t === 'TF').map((o) => ({
|
||||
schema: o.s as string, name: o.n as string, returnsSet: o.t !== 'FN', columns: o.t === 'FN' ? undefined : columnsOf(o.id),
|
||||
args: params.filter((p) => p.id === o.id).map((p) => ({ name: p.name as string, type: logical(p.type as string), nativeType: p.type as string, hasDefault: false })),
|
||||
}));
|
||||
return { tables: [...tables.values()], functions };
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { SqlRenderer, type CatalogColumn, type CatalogFunction, type Filter, type OrderTerm, type RenderContext, type Resource, type Statement } from '@crudy/restloom';
|
||||
|
||||
/**
|
||||
* SQL Server 2022: bracketed names, @p placeholders, JSON_OBJECT for rows and
|
||||
* STRING_AGG for arrays (JSON_ARRAYAGG is not in 2022), OFFSET/FETCH paging,
|
||||
* OUTPUT in place of RETURNING.
|
||||
*/
|
||||
export class MssqlRenderer extends SqlRenderer {
|
||||
quote(identifier: string): string {
|
||||
return `[${identifier.replace(/]/g, ']]')}]`;
|
||||
}
|
||||
|
||||
literal(text: string): string {
|
||||
return `N'${text.replace(/'/g, "''")}'`;
|
||||
}
|
||||
|
||||
placeholder(index: number): string {
|
||||
return `@p${index}`;
|
||||
}
|
||||
|
||||
jsonObject(pairs: [string, string][]): string {
|
||||
return `JSON_OBJECT(${pairs.map(([k, v]) => `${this.literal(k)}: ${v}`).join(', ')} NULL ON NULL)`;
|
||||
}
|
||||
|
||||
jsonArray(rowExpr: string, orderExpr: string): string {
|
||||
return `COALESCE(N'[' + STRING_AGG(CAST(${rowExpr} AS nvarchar(max)), N',') WITHIN GROUP (ORDER BY ${orderExpr}) + N']', N'[]')`;
|
||||
}
|
||||
|
||||
nested(expr: string): string {
|
||||
return `JSON_QUERY(${expr})`;
|
||||
}
|
||||
|
||||
jsonValue(expr: string, column: CatalogColumn): string {
|
||||
if (column.type === 'boolean') return `CAST(${expr} AS bit)`;
|
||||
if (column.nativeType === 'datetimeoffset') return `CONVERT(varchar(19), SWITCHOFFSET(${expr}, '+00:00'), 126) + '+00:00'`;
|
||||
if (column.type === 'timestamp') return `CONVERT(varchar(19), ${expr}, 126)`;
|
||||
if (column.type === 'date') return `CONVERT(varchar(10), ${expr}, 23)`;
|
||||
return expr;
|
||||
}
|
||||
|
||||
like(expr: string, pattern: string, insensitive: boolean): string {
|
||||
return insensitive
|
||||
? `LOWER(${expr}) LIKE LOWER(${pattern}) ESCAPE '\\'`
|
||||
: `${expr} COLLATE Latin1_General_100_CS_AS LIKE ${pattern} ESCAPE '\\'`;
|
||||
}
|
||||
|
||||
isBoolean(expr: string, value: boolean): string {
|
||||
return `${expr} = ${value ? 1 : 0}`;
|
||||
}
|
||||
|
||||
/** SQL Server 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 `CASE WHEN ${expr} IS NULL THEN ${first ? 0 : 1} ELSE ${first ? 1 : 0} END, ${expr} ${desc ? 'DESC' : 'ASC'}`;
|
||||
}
|
||||
|
||||
/** OFFSET/FETCH needs an ORDER BY, so an unordered read orders by nothing in particular. */
|
||||
order(terms: OrderTerm[], alias: string, ctx: RenderContext): string {
|
||||
return super.order(terms, alias, ctx) || '(SELECT NULL)';
|
||||
}
|
||||
|
||||
page(limit: number | undefined, offset: number | undefined): string {
|
||||
if (limit === undefined && !offset) return '';
|
||||
return ` OFFSET ${offset ?? 0} ROWS${limit !== undefined ? ` FETCH NEXT ${limit} ROWS ONLY` : ''}`;
|
||||
}
|
||||
|
||||
first(selectSql: string): string {
|
||||
return selectSql.replace(/^SELECT /, 'SELECT TOP 1 ');
|
||||
}
|
||||
|
||||
aggregate(fn: Parameters<SqlRenderer['aggregate']>[0], expr: string | null): string {
|
||||
return fn === 'count' ? 'COUNT_BIG(*)' : super.aggregate(fn, expr);
|
||||
}
|
||||
|
||||
private output(resource: Resource, side: 'INSERTED' | 'DELETED'): string {
|
||||
return ` OUTPUT ${resource.key.map((k) => `${side}.${this.quote(k)}`).join(', ')}`;
|
||||
}
|
||||
|
||||
insert(resource: Resource, columns: string[], rows: unknown[][], ctx: RenderContext): Statement {
|
||||
const values = rows.map((row) => `(${row.map((v) => ctx.params.add(v)).join(', ')})`).join(', ');
|
||||
const sql = `INSERT INTO ${this.table(resource.schema, resource.table)} (${columns.map((c) => this.quote(c)).join(', ')})${this.output(resource, 'INSERTED')} VALUES ${values}`;
|
||||
return ctx.params.finalize(sql, (i) => this.placeholder(i));
|
||||
}
|
||||
|
||||
update(resource: Resource, set: [string, unknown][], where: Filter[], ctx: RenderContext): Statement {
|
||||
const alias = ctx.alias();
|
||||
const assignments = set.map(([c, v]) => `${this.column(alias, c)} = ${ctx.params.add(v)}`).join(', ');
|
||||
const conditions = this.filters(resource, alias, where, ctx);
|
||||
const sql = `UPDATE ${alias} SET ${assignments}${this.output(resource, 'INSERTED')} FROM ${this.table(resource.schema, resource.table)} ${alias}${conditions.length ? ` WHERE ${conditions.join(' AND ')}` : ''}`;
|
||||
return ctx.params.finalize(sql, (i) => this.placeholder(i));
|
||||
}
|
||||
|
||||
delete(resource: Resource, where: Filter[], ctx: RenderContext): Statement {
|
||||
const alias = ctx.alias();
|
||||
const conditions = this.filters(resource, alias, where, ctx);
|
||||
const sql = `DELETE ${alias}${this.output(resource, 'DELETED')} FROM ${this.table(resource.schema, resource.table)} ${alias}${conditions.length ? ` WHERE ${conditions.join(' AND ')}` : ''}`;
|
||||
return ctx.params.finalize(sql, (i) => this.placeholder(i));
|
||||
}
|
||||
|
||||
/** T-SQL calls by position. A scalar comes back as JSON text through JSON_ARRAY, a table function row by row. */
|
||||
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(', ')})`;
|
||||
const row = fn.columns?.length
|
||||
? this.jsonObject(fn.columns.map((c) => [c.name, this.jsonValue(this.column('x', c.name), c)]))
|
||||
: '(SELECT x.* FOR JSON PATH, WITHOUT_ARRAY_WRAPPER, INCLUDE_NULL_VALUES)';
|
||||
const sql = fn.returnsSet
|
||||
? `SELECT ${row} AS r FROM ${call} x`
|
||||
: `SELECT SUBSTRING(j, 2, LEN(j + N'x') - 3) AS r FROM (SELECT JSON_ARRAY(${call} NULL ON NULL) AS j) v`;
|
||||
return ctx.params.finalize(sql, (i) => this.placeholder(i));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { LogicalType } from '@crudy/restloom';
|
||||
|
||||
/** A SQL Server type name as a logical type. JSON lives in text columns, so it reads as text. */
|
||||
export function logical(type: string): LogicalType {
|
||||
const t = type.toLowerCase();
|
||||
if (['tinyint', 'smallint', 'int', 'bigint'].includes(t)) return 'integer';
|
||||
if (['decimal', 'numeric', 'money', 'smallmoney', 'float', 'real'].includes(t)) return 'number';
|
||||
if (t === 'bit') return 'boolean';
|
||||
if (t === 'uniqueidentifier') return 'uuid';
|
||||
if (t === 'date') return 'date';
|
||||
if (['datetime', 'datetime2', 'smalldatetime', 'datetimeoffset'].includes(t)) return 'timestamp';
|
||||
if (t === 'time') return 'time';
|
||||
if (['char', 'varchar', 'nchar', 'nvarchar', 'text', 'ntext', 'sysname'].includes(t)) return 'text';
|
||||
if (['binary', 'varbinary', 'image', 'rowversion', 'timestamp'].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