feat: MySQL, SQL Server and SQLite dialects; writes without RETURNING; one null order on every database
This commit is contained in:
@@ -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));
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user