feat: MySQL, SQL Server and SQLite dialects; writes without RETURNING; one null order on every database
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "@crudy/restloom-sqlite",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "SQLite dialect for restloom",
|
||||
"keywords": [
|
||||
"restloom",
|
||||
"sqlite",
|
||||
"rest",
|
||||
"api"
|
||||
],
|
||||
"author": "Marc Villain",
|
||||
"homepage": "https://restloom.crudy.fr",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/crudylabs/restloom.git",
|
||||
"directory": "packages/sqlite"
|
||||
},
|
||||
"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": {
|
||||
"better-sqlite3": ">=12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/better-sqlite3": "^7.6.0",
|
||||
"better-sqlite3": "^12.11.1",
|
||||
"typescript": "^5.9.0"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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: [] };
|
||||
}
|
||||
@@ -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}` : ''}`;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": { "outDir": "dist", "rootDir": "src" },
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user