feat: MySQL, SQL Server and SQLite dialects; writes without RETURNING; one null order on every database
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
# Builds every package and runs the unit tests and the conformance suite
|
||||
# against each database. The organisation only allows its own actions, so
|
||||
# against Postgres, MySQL and SQLite. SQL Server runs locally only: its
|
||||
# image needs its licence accepted, which stays the maintainer's decision. The organisation only allows its own actions, so
|
||||
# checkout and Node setup are plain shell; databases are service containers.
|
||||
name: ci
|
||||
|
||||
@@ -17,6 +18,7 @@ jobs:
|
||||
env:
|
||||
NODE_VERSION: v24.12.0
|
||||
RESTLOOM_PG_URL: postgres://postgres@localhost:5432/postgres
|
||||
RESTLOOM_MYSQL_URL: mysql://root@127.0.0.1:3306
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
@@ -25,6 +27,13 @@ jobs:
|
||||
ports: ['5432:5432']
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U postgres" --health-interval 2s --health-timeout 2s --health-retries 30
|
||||
mysql:
|
||||
image: mysql:8.4
|
||||
env:
|
||||
MYSQL_ALLOW_EMPTY_PASSWORD: 'yes'
|
||||
ports: ['3306:3306']
|
||||
options: >-
|
||||
--health-cmd "mysqladmin ping -uroot" --health-interval 3s --health-timeout 3s --health-retries 40
|
||||
steps:
|
||||
- name: Check out
|
||||
run: |
|
||||
|
||||
@@ -11,4 +11,5 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
- Writes: create, update and delete by key, bulk by filter where enabled, scope applied, rows read back with `Prefer: return=representation`.
|
||||
- Procedures: database functions at declared paths, arguments checked by name and type.
|
||||
- `@crudy/restloom-postgres`: the Postgres dialect.
|
||||
- `@crudy/restloom-mysql`, `@crudy/restloom-mssql` and `@crudy/restloom-sqlite`: MySQL 8, SQL Server 2022 and SQLite dialects, each passing the conformance suite.
|
||||
- `@crudy/restloom-fastify` and `@crudy/restloom-express`: framework adapters.
|
||||
|
||||
@@ -25,6 +25,9 @@ Your backend stays in charge:
|
||||
|---|---|
|
||||
| `@crudy/restloom` | The core: grammar, discovery, `createREST`, a Node `http` listener |
|
||||
| `@crudy/restloom-postgres` | PostgreSQL |
|
||||
| `@crudy/restloom-mysql` | MySQL |
|
||||
| `@crudy/restloom-mssql` | SQL Server |
|
||||
| `@crudy/restloom-sqlite` | SQLite |
|
||||
| `@crudy/restloom-fastify` | Fastify plugin |
|
||||
| `@crudy/restloom-express` | Express handler |
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
IF OBJECT_ID('demo.assets_tagged') IS NOT NULL DROP FUNCTION demo.assets_tagged;
|
||||
IF OBJECT_ID('demo.incidents') IS NOT NULL DROP TABLE demo.incidents;
|
||||
IF OBJECT_ID('demo.asset_tags') IS NOT NULL DROP TABLE demo.asset_tags;
|
||||
IF OBJECT_ID('demo.tags') IS NOT NULL DROP TABLE demo.tags;
|
||||
IF OBJECT_ID('demo.assets') IS NOT NULL DROP TABLE demo.assets;
|
||||
IF OBJECT_ID('demo.users') IS NOT NULL DROP TABLE demo.users;
|
||||
IF SCHEMA_ID('demo') IS NOT NULL DROP SCHEMA demo;
|
||||
GO
|
||||
CREATE SCHEMA demo;
|
||||
GO
|
||||
CREATE TABLE demo.users (
|
||||
id INT PRIMARY KEY,
|
||||
name NVARCHAR(100) NOT NULL,
|
||||
email NVARCHAR(200),
|
||||
password_hash NVARCHAR(200),
|
||||
[limit] INT,
|
||||
manager_id INT REFERENCES demo.users (id)
|
||||
);
|
||||
|
||||
CREATE TABLE demo.assets (
|
||||
id INT PRIMARY KEY,
|
||||
name NVARCHAR(100) NOT NULL,
|
||||
status NVARCHAR(20) NOT NULL,
|
||||
owner_id INT REFERENCES demo.users (id),
|
||||
cost DECIMAL(10, 2),
|
||||
active BIT NOT NULL DEFAULT 1,
|
||||
created_at DATETIMEOFFSET(0) NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE demo.tags (
|
||||
id INT PRIMARY KEY,
|
||||
name NVARCHAR(100) NOT NULL UNIQUE
|
||||
);
|
||||
|
||||
CREATE TABLE demo.asset_tags (
|
||||
asset_id INT NOT NULL REFERENCES demo.assets (id),
|
||||
tag_id INT NOT NULL REFERENCES demo.tags (id),
|
||||
PRIMARY KEY (asset_id, tag_id)
|
||||
);
|
||||
|
||||
CREATE TABLE demo.incidents (
|
||||
id INT PRIMARY KEY,
|
||||
title NVARCHAR(200) NOT NULL,
|
||||
asset_id INT NOT NULL REFERENCES demo.assets (id),
|
||||
severity INT NOT NULL,
|
||||
opened_on DATE NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO demo.users (id, name, email, password_hash, [limit], manager_id) VALUES
|
||||
(1, 'Ada', 'ada@example.com', 'x', 5, NULL),
|
||||
(2, 'Grace', 'grace@example.com', 'x', NULL, 1),
|
||||
(3, 'Linus', NULL, 'x', 2, 1);
|
||||
|
||||
INSERT INTO demo.assets (id, name, status, owner_id, cost, active, created_at) VALUES
|
||||
(1, 'vpn gateway', 'open', 1, 1200.50, 1, '2026-01-02T10:00:00+00:00'),
|
||||
(2, 'mail relay', 'open', 2, 300.00, 1, '2026-01-05T09:30:00+00:00'),
|
||||
(3, 'old wiki', 'retired', NULL, NULL, 0, '2025-06-01T00:00:00+00:00'),
|
||||
(4, 'payroll db', 'open', 3, 4000.00, 1, '2026-02-11T16:45:00+00:00');
|
||||
|
||||
INSERT INTO demo.tags (id, name) VALUES (1, 'pci'), (2, 'gdpr'), (3, 'internal');
|
||||
|
||||
INSERT INTO demo.asset_tags (asset_id, tag_id) VALUES (1, 1), (1, 3), (2, 2), (4, 1), (4, 2);
|
||||
|
||||
INSERT INTO demo.incidents (id, title, asset_id, severity, opened_on) VALUES
|
||||
(1, 'VPN outage', 1, 3, '2026-01-10'),
|
||||
(2, 'Spoofed mail', 2, 2, '2026-02-01'),
|
||||
(3, 'Brute force', 1, 4, '2026-03-05');
|
||||
GO
|
||||
CREATE FUNCTION demo.assets_tagged(@tag NVARCHAR(100)) RETURNS TABLE AS RETURN (
|
||||
SELECT a.* FROM demo.assets a
|
||||
JOIN demo.asset_tags t ON t.asset_id = a.id
|
||||
JOIN demo.tags g ON g.id = t.tag_id
|
||||
WHERE g.name = @tag
|
||||
);
|
||||
@@ -0,0 +1,74 @@
|
||||
SET GLOBAL log_bin_trust_function_creators = 1;
|
||||
DROP DATABASE IF EXISTS demo;
|
||||
CREATE DATABASE demo CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
|
||||
|
||||
CREATE TABLE demo.users (
|
||||
id INT PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
email VARCHAR(200),
|
||||
password_hash VARCHAR(200),
|
||||
`limit` INT,
|
||||
manager_id INT,
|
||||
FOREIGN KEY (manager_id) REFERENCES demo.users (id)
|
||||
);
|
||||
|
||||
CREATE TABLE demo.assets (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
status VARCHAR(20) NOT NULL,
|
||||
owner_id INT,
|
||||
cost DECIMAL(10, 2),
|
||||
active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
FOREIGN KEY (owner_id) REFERENCES demo.users (id)
|
||||
);
|
||||
|
||||
CREATE TABLE demo.tags (
|
||||
id INT PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL UNIQUE
|
||||
);
|
||||
|
||||
CREATE TABLE demo.asset_tags (
|
||||
asset_id INT NOT NULL,
|
||||
tag_id INT NOT NULL,
|
||||
PRIMARY KEY (asset_id, tag_id),
|
||||
FOREIGN KEY (asset_id) REFERENCES demo.assets (id),
|
||||
FOREIGN KEY (tag_id) REFERENCES demo.tags (id)
|
||||
);
|
||||
|
||||
CREATE TABLE demo.incidents (
|
||||
id INT PRIMARY KEY,
|
||||
title VARCHAR(200) NOT NULL,
|
||||
asset_id INT NOT NULL,
|
||||
severity INT NOT NULL,
|
||||
opened_on DATE NOT NULL,
|
||||
FOREIGN KEY (asset_id) REFERENCES demo.assets (id)
|
||||
);
|
||||
|
||||
SET time_zone = '+00:00';
|
||||
|
||||
INSERT INTO demo.users (id, name, email, password_hash, `limit`, manager_id) VALUES
|
||||
(1, 'Ada', 'ada@example.com', 'x', 5, NULL),
|
||||
(2, 'Grace', 'grace@example.com', 'x', NULL, 1),
|
||||
(3, 'Linus', NULL, 'x', 2, 1);
|
||||
|
||||
INSERT INTO demo.assets (id, name, status, owner_id, cost, active, created_at) VALUES
|
||||
(1, 'vpn gateway', 'open', 1, 1200.50, 1, '2026-01-02 10:00:00'),
|
||||
(2, 'mail relay', 'open', 2, 300.00, 1, '2026-01-05 09:30:00'),
|
||||
(3, 'old wiki', 'retired', NULL, NULL, 0, '2025-06-01 00:00:00'),
|
||||
(4, 'payroll db', 'open', 3, 4000.00, 1, '2026-02-11 16:45:00');
|
||||
|
||||
INSERT INTO demo.tags (id, name) VALUES (1, 'pci'), (2, 'gdpr'), (3, 'internal');
|
||||
|
||||
INSERT INTO demo.asset_tags (asset_id, tag_id) VALUES (1, 1), (1, 3), (2, 2), (4, 1), (4, 2);
|
||||
|
||||
INSERT INTO demo.incidents (id, title, asset_id, severity, opened_on) VALUES
|
||||
(1, 'VPN outage', 1, 3, '2026-01-10'),
|
||||
(2, 'Spoofed mail', 2, 2, '2026-02-01'),
|
||||
(3, 'Brute force', 1, 4, '2026-03-05');
|
||||
|
||||
CREATE FUNCTION demo.retire_asset(asset INT, reason TEXT) RETURNS INT MODIFIES SQL DATA
|
||||
BEGIN
|
||||
UPDATE demo.assets SET status = 'retired' WHERE id = asset;
|
||||
RETURN asset;
|
||||
END;
|
||||
@@ -0,0 +1,57 @@
|
||||
CREATE TABLE demo.users (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
email TEXT,
|
||||
password_hash TEXT,
|
||||
"limit" INTEGER,
|
||||
manager_id INTEGER REFERENCES users (id)
|
||||
);
|
||||
|
||||
CREATE TABLE demo.assets (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
owner_id INTEGER REFERENCES users (id),
|
||||
cost NUMERIC,
|
||||
active BOOLEAN NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE demo.tags (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE
|
||||
);
|
||||
|
||||
CREATE TABLE demo.asset_tags (
|
||||
asset_id INTEGER NOT NULL REFERENCES assets (id),
|
||||
tag_id INTEGER NOT NULL REFERENCES tags (id),
|
||||
PRIMARY KEY (asset_id, tag_id)
|
||||
);
|
||||
|
||||
CREATE TABLE demo.incidents (
|
||||
id INTEGER PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
asset_id INTEGER NOT NULL REFERENCES assets (id),
|
||||
severity INTEGER NOT NULL,
|
||||
opened_on DATE NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO demo.users (id, name, email, password_hash, "limit", manager_id) VALUES
|
||||
(1, 'Ada', 'ada@example.com', 'x', 5, NULL),
|
||||
(2, 'Grace', 'grace@example.com', 'x', NULL, 1),
|
||||
(3, 'Linus', NULL, 'x', 2, 1);
|
||||
|
||||
INSERT INTO demo.assets (id, name, status, owner_id, cost, active, created_at) VALUES
|
||||
(1, 'vpn gateway', 'open', 1, 1200.50, 1, '2026-01-02T10:00:00+00:00'),
|
||||
(2, 'mail relay', 'open', 2, 300.00, 1, '2026-01-05T09:30:00+00:00'),
|
||||
(3, 'old wiki', 'retired', NULL, NULL, 0, '2025-06-01T00:00:00+00:00'),
|
||||
(4, 'payroll db', 'open', 3, 4000.00, 1, '2026-02-11T16:45:00+00:00');
|
||||
|
||||
INSERT INTO demo.tags (id, name) VALUES (1, 'pci'), (2, 'gdpr'), (3, 'internal');
|
||||
|
||||
INSERT INTO demo.asset_tags (asset_id, tag_id) VALUES (1, 1), (1, 3), (2, 2), (4, 1), (4, 2);
|
||||
|
||||
INSERT INTO demo.incidents (id, title, asset_id, severity, opened_on) VALUES
|
||||
(1, 'VPN outage', 1, 3, '2026-01-10'),
|
||||
(2, 'Spoofed mail', 2, 2, '2026-02-01'),
|
||||
(3, 'Brute force', 1, 4, '2026-03-05');
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, test, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import { createREST } from '../packages/core/dist/index.js';
|
||||
import { mssql } from '../packages/mssql/dist/index.js';
|
||||
import { examples, run } from './examples.mjs';
|
||||
import { PAGES, optionsFor, skipFor } from './pages.mjs';
|
||||
|
||||
const require = createRequire(new URL('../packages/mssql/package.json', import.meta.url));
|
||||
const driver = require('mssql');
|
||||
const url = process.env.RESTLOOM_MSSQL_URL;
|
||||
const batches = readFileSync(new URL('./fixtures/mssql.sql', import.meta.url), 'utf8').split(/^GO$/m);
|
||||
|
||||
let pool;
|
||||
before(async () => { if (url) pool = await new driver.ConnectionPool(url).connect(); });
|
||||
after(async () => { await pool?.close(); });
|
||||
|
||||
for (const page of PAGES) {
|
||||
describe(`mssql ${page}`, { skip: !url && 'RESTLOOM_MSSQL_URL is not set' }, () => {
|
||||
let rest;
|
||||
before(async () => {
|
||||
for (const batch of batches) await pool.request().batch(batch);
|
||||
const db = mssql(pool);
|
||||
rest = createREST({ db, ...optionsFor(db) });
|
||||
});
|
||||
for (const example of examples(new URL(`../docs/${page}`, import.meta.url).pathname)) {
|
||||
test(example.name, { skip: skipFor('mssql', example) }, () => run(rest, example, assert));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, test, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import { createREST } from '../packages/core/dist/index.js';
|
||||
import { mysql } from '../packages/mysql/dist/index.js';
|
||||
import { examples, run } from './examples.mjs';
|
||||
import { PAGES, optionsFor, skipFor } from './pages.mjs';
|
||||
|
||||
const require = createRequire(new URL('../packages/mysql/package.json', import.meta.url));
|
||||
const { createPool } = require('mysql2/promise');
|
||||
const url = process.env.RESTLOOM_MYSQL_URL;
|
||||
const fixture = readFileSync(new URL('./fixtures/mysql.sql', import.meta.url), 'utf8');
|
||||
|
||||
let pool;
|
||||
let admin;
|
||||
before(() => {
|
||||
if (!url) return;
|
||||
pool = createPool({ uri: url, connectionLimit: 4 });
|
||||
admin = createPool({ uri: url, connectionLimit: 1, multipleStatements: true });
|
||||
});
|
||||
after(async () => { await pool?.end(); await admin?.end(); });
|
||||
|
||||
for (const page of PAGES) {
|
||||
describe(`mysql ${page}`, { skip: !url && 'RESTLOOM_MYSQL_URL is not set' }, () => {
|
||||
let rest;
|
||||
before(async () => {
|
||||
await admin.query(fixture);
|
||||
const db = mysql(pool);
|
||||
rest = createREST({ db, ...optionsFor(db) });
|
||||
});
|
||||
for (const example of examples(new URL(`../docs/${page}`, import.meta.url).pathname)) {
|
||||
test(example.name, { skip: skipFor('mysql', example) }, () => run(rest, example, assert));
|
||||
}
|
||||
});
|
||||
}
|
||||
+29
-4
@@ -1,6 +1,22 @@
|
||||
/** The docs pages whose examples are conformance cases, and the options every database runs them with. */
|
||||
export const PAGES = ['query-grammar.md', 'writes.md'];
|
||||
|
||||
const PROCEDURES = {
|
||||
'assets/{asset}/retire': { function: 'demo.retire_asset' },
|
||||
'tags/{tag}/assets': { function: 'demo.assets_tagged' },
|
||||
};
|
||||
|
||||
/**
|
||||
* The procedures each dialect's fixture defines: SQLite has no functions,
|
||||
* MySQL none returning a set, and a SQL Server function cannot change data.
|
||||
*/
|
||||
const AVAILABLE = {
|
||||
postgres: Object.keys(PROCEDURES),
|
||||
mssql: ['tags/{tag}/assets'],
|
||||
mysql: ['assets/{asset}/retire'],
|
||||
sqlite: [],
|
||||
};
|
||||
|
||||
export const OPTIONS = {
|
||||
expose: { schemas: ['demo'] },
|
||||
resources: {
|
||||
@@ -9,8 +25,17 @@ export const OPTIONS = {
|
||||
assets: { write: { create: ['name', 'status', 'owner_id', 'cost'], update: ['name', 'status', 'owner_id'], delete: false } },
|
||||
incidents: { write: { create: true, update: true, delete: true, bulk: true } },
|
||||
},
|
||||
procedures: {
|
||||
'assets/{asset}/retire': { function: 'demo.retire_asset' },
|
||||
'tags/{tag}/assets': { function: 'demo.assets_tagged' },
|
||||
},
|
||||
};
|
||||
|
||||
export function optionsFor(dialect) {
|
||||
const procedures = Object.fromEntries(AVAILABLE[dialect.name].map((t) => [t, PROCEDURES[t]]));
|
||||
return Object.keys(procedures).length ? { ...OPTIONS, procedures } : OPTIONS;
|
||||
}
|
||||
|
||||
const pattern = (t) => new RegExp(`^/${t.replace(/\{[^}]+\}/g, '[^/]+')}$`);
|
||||
|
||||
/** Why a dialect skips an example, or false. */
|
||||
export function skipFor(name, example) {
|
||||
const missing = Object.keys(PROCEDURES).filter((t) => !AVAILABLE[name].includes(t));
|
||||
return missing.some((t) => pattern(t).test(example.path)) && `${name} has no such function`;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { createRequire } from 'node:module';
|
||||
import { createREST } from '../packages/core/dist/index.js';
|
||||
import { postgres } from '../packages/postgres/dist/index.js';
|
||||
import { examples, run } from './examples.mjs';
|
||||
import { PAGES, OPTIONS } from './pages.mjs';
|
||||
import { PAGES, optionsFor } from './pages.mjs';
|
||||
|
||||
const require = createRequire(new URL('../packages/postgres/package.json', import.meta.url));
|
||||
const { Pool } = require('pg');
|
||||
@@ -21,7 +21,8 @@ for (const page of PAGES) {
|
||||
let rest;
|
||||
before(async () => {
|
||||
await pool.query(fixture);
|
||||
rest = createREST({ db: postgres(pool), ...OPTIONS });
|
||||
const dialect = postgres(pool);
|
||||
rest = createREST({ db: dialect, ...optionsFor(dialect) });
|
||||
});
|
||||
for (const example of examples(new URL(`../docs/${page}`, import.meta.url).pathname)) {
|
||||
test(example.name, () => run(rest, example, assert));
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, test, before } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import { createREST } from '../packages/core/dist/index.js';
|
||||
import { sqlite } from '../packages/sqlite/dist/index.js';
|
||||
import { examples, run } from './examples.mjs';
|
||||
import { PAGES, optionsFor, skipFor } from './pages.mjs';
|
||||
|
||||
const require = createRequire(new URL('../packages/sqlite/package.json', import.meta.url));
|
||||
const Database = require('better-sqlite3');
|
||||
const fixture = readFileSync(new URL('./fixtures/sqlite.sql', import.meta.url), 'utf8');
|
||||
|
||||
for (const page of PAGES) {
|
||||
describe(`sqlite ${page}`, () => {
|
||||
const db = new Database(':memory:');
|
||||
const dialect = sqlite(db);
|
||||
let rest;
|
||||
before(() => {
|
||||
db.exec("ATTACH ':memory:' AS demo");
|
||||
db.exec(fixture);
|
||||
rest = createREST({ db: dialect, ...optionsFor(dialect) });
|
||||
});
|
||||
for (const example of examples(new URL(`../docs/${page}`, import.meta.url).pathname)) {
|
||||
test(example.name, { skip: skipFor('sqlite', example) }, () => run(rest, example, assert));
|
||||
}
|
||||
});
|
||||
}
|
||||
+55
-3
@@ -2,9 +2,14 @@
|
||||
|
||||
A dialect is the package that knows one database: how to read its catalog, how to write its SQL, how to run a transaction and what its errors mean.
|
||||
|
||||
| Package | Database | Embeds | Writes return rows | Estimated count | Row security |
|
||||
|---|---|---|---|---|---|
|
||||
| `@crudy/restloom-postgres` | PostgreSQL 13+ | `json_build_object`, `json_agg` | `RETURNING` | planner estimate | yes |
|
||||
| Package | Database | Embeds | Written keys | Estimated count | Row security | Procedures |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `@crudy/restloom-postgres` | PostgreSQL 13+ | `json_build_object`, `json_agg` | `RETURNING` | planner estimate | yes | functions, scalar or set |
|
||||
| `@crudy/restloom-mysql` | MySQL 8.0+ | `JSON_OBJECT`, `GROUP_CONCAT` | locked and read before writing | no | no | scalar functions |
|
||||
| `@crudy/restloom-mssql` | SQL Server 2022+ | `JSON_OBJECT`, `STRING_AGG` | `OUTPUT` | no | security policies | scalar and table functions |
|
||||
| `@crudy/restloom-sqlite` | SQLite 3.44+ (better-sqlite3) | `json_object`, `json_group_array` | `RETURNING` | no | no | none |
|
||||
|
||||
Every dialect passes the same conformance suite, so a request answers the same rows on each. Where they differ, the sections below say how.
|
||||
|
||||
## Postgres
|
||||
|
||||
@@ -19,6 +24,53 @@ const db = postgres(pool);
|
||||
- **Timestamps:** they are written in the session's time zone, as Postgres writes them in JSON.
|
||||
- **Procedures:** calls use named arguments, so the order in the body does not matter.
|
||||
|
||||
## MySQL
|
||||
|
||||
```ts
|
||||
import { createPool } from 'mysql2/promise';
|
||||
import { mysql } from '@crudy/restloom-mysql';
|
||||
|
||||
const db = mysql(createPool({ uri: process.env.DATABASE_URL }));
|
||||
```
|
||||
|
||||
- **Schemas:** MySQL's schemas are its databases: `expose: { schemas: ['shop'] }`.
|
||||
- **Session:** every request runs in UTC. `MAX_EXECUTION_TIME` applies the timeout, which MySQL enforces on reads only.
|
||||
- **Writes without RETURNING:** an update or delete first locks and reads the keys it will reach, then writes by key. An insert takes its keys from the body, or from `AUTO_INCREMENT` for a single-column key.
|
||||
- **Text comparison:** text compares with each column's collation, so with a case-insensitive collation `eq` ignores case. `like` is always case-sensitive and `ilike` never is.
|
||||
- **Timestamps:** written as ISO 8601. A `TIMESTAMP` carries `+00:00`; a `DATETIME`, which has no zone, carries none.
|
||||
- **Procedures:** stored functions take every argument, in any order in the body; MySQL has none returning a set.
|
||||
|
||||
## SQL Server
|
||||
|
||||
```ts
|
||||
import sql from 'mssql';
|
||||
import { mssql } from '@crudy/restloom-mssql';
|
||||
|
||||
const db = mssql(await new sql.ConnectionPool(process.env.DATABASE_URL).connect());
|
||||
```
|
||||
|
||||
- **Version:** SQL Server 2022 or later, for `JSON_OBJECT`.
|
||||
- **Timeout:** the statement timeout is the pool's `requestTimeout`, since the driver sets it per pool, not per request.
|
||||
- **Text comparison:** as for MySQL, it follows the column's collation. `like` is case-sensitive, `ilike` is not.
|
||||
- **Timestamps:** written to the second. A `datetimeoffset` is written in UTC with `+00:00`.
|
||||
- **Row security:** security policies read `SESSION_CONTEXT`; set it in the `transaction` hook with `sp_set_session_context`.
|
||||
- **Procedures:** scalar and table-valued functions; T-SQL does not expose parameter defaults, so every argument is required. A T-SQL function cannot change data.
|
||||
|
||||
## SQLite
|
||||
|
||||
```ts
|
||||
import Database from 'better-sqlite3';
|
||||
import { sqlite } from '@crudy/restloom-sqlite';
|
||||
|
||||
const db = sqlite(new Database('app.db'));
|
||||
```
|
||||
|
||||
- **Schemas:** the main database is `main`, and an attached database is a schema by its name.
|
||||
- **Connection settings:** foreign keys and case-sensitive `LIKE` are turned on for the connection.
|
||||
- **One connection:** requests run one transaction at a time, queued.
|
||||
- **Limits of SQLite:** it cannot stop a running statement, so `limits.timeoutMs` does not apply. It has no functions to call as procedures.
|
||||
- **Booleans:** stored as 0 and 1, answered as `true` and `false`.
|
||||
|
||||
## Changing how SQL is written
|
||||
|
||||
Every piece of SQL comes from one overridable method of the dialect's renderer. To change one, subclass it and pass it in:
|
||||
|
||||
@@ -11,6 +11,9 @@ npm install @crudy/restloom @crudy/restloom-postgres pg
|
||||
| Database | Package | Driver |
|
||||
|---|---|---|
|
||||
| PostgreSQL 13+ | `@crudy/restloom-postgres` | `pg` |
|
||||
| MySQL 8.0+ | `@crudy/restloom-mysql` | `mysql2` |
|
||||
| SQL Server 2022+ | `@crudy/restloom-mssql` | `mssql` |
|
||||
| SQLite 3.44+ | `@crudy/restloom-sqlite` | `better-sqlite3` |
|
||||
|
||||
## Serve a schema
|
||||
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ restloom keeps what makes PostgREST pleasant, the catalog as the source and a qu
|
||||
| | PostgREST | restloom |
|
||||
|---|---|---|
|
||||
| Runs as | a separate server | a library inside your backend |
|
||||
| Databases | PostgreSQL | PostgreSQL; more through dialects |
|
||||
| Databases | PostgreSQL | PostgreSQL, MySQL, SQL Server, SQLite |
|
||||
| What is served | everything the role can reach in the schema | what you name in `expose`, minus hidden columns |
|
||||
| Sensitive columns | served unless the role cannot read them | hidden by name unless shown |
|
||||
| Writes | everything the role may write | off until a resource enables them, by column |
|
||||
|
||||
@@ -173,6 +173,15 @@ GET /assets?select=id&or=(status.eq.retired,owner.name.eq.Linus)
|
||||
- **To-one paths:** sort by a related column: `order=owner.name.desc`.
|
||||
- **To-many relations:** sort by how many related rows each row has: `order=incidents.count.desc`. A to-many relation cannot be sorted by one of its columns, because a row has many values.
|
||||
- **Stable pages:** the key is appended to every order.
|
||||
- **Nulls:** they sort last ascending and first descending, as in Postgres, on every database.
|
||||
|
||||
```http
|
||||
GET /assets?select=id,cost&order=cost.desc
|
||||
```
|
||||
|
||||
```json
|
||||
[{ "id": 3, "cost": null }, { "id": 4, "cost": 4000 }, { "id": 1, "cost": 1200.5 }, { "id": 2, "cost": 300 }]
|
||||
```
|
||||
|
||||
## Pages and counts
|
||||
|
||||
|
||||
Generated
+1475
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,9 @@
|
||||
"workspaces": [
|
||||
"packages/core",
|
||||
"packages/postgres",
|
||||
"packages/sqlite",
|
||||
"packages/mysql",
|
||||
"packages/mssql",
|
||||
"packages/fastify",
|
||||
"packages/express"
|
||||
],
|
||||
@@ -15,5 +18,8 @@
|
||||
"build": "npm run build --workspaces --if-present",
|
||||
"test": "npm run build && node --test 'packages/*/test/*.test.mjs' 'conformance/*.test.mjs'",
|
||||
"check": "npm run build && npm test"
|
||||
},
|
||||
"allowScripts": {
|
||||
"better-sqlite3@12.11.1": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -20,7 +20,8 @@ export interface PostgresOptions {
|
||||
export function postgres(pool: Pool, options: PostgresOptions = {}): Dialect {
|
||||
return {
|
||||
name: 'postgres',
|
||||
capabilities: { returning: true, estimatedCount: true, rowSecurity: true },
|
||||
capabilities: { returning: true, estimatedCount: true, rowSecurity: true, procedures: true },
|
||||
decode: (value) => value,
|
||||
renderer: options.renderer ?? new PostgresRenderer(),
|
||||
introspect: (scope) => introspect(pool, scope),
|
||||
async transaction(fn, { timeoutMs, readOnly }) {
|
||||
|
||||
@@ -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