feat: Prefer count header, procedures outside exposed schemas; 0.2.0
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@crudy/restloom",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"type": "module",
|
||||
"description": "A REST API generated from your database's catalog: discovery, a curl-friendly query grammar, relations of every kind, safe defaults",
|
||||
"keywords": ["rest", "api", "postgrest", "sql", "database", "crud", "generator"],
|
||||
|
||||
@@ -23,8 +23,11 @@ export interface Dialect {
|
||||
readonly name: string;
|
||||
readonly capabilities: Capabilities;
|
||||
readonly renderer: SqlRenderer;
|
||||
/** Reads the tables, views and functions of the given schemas and tables. */
|
||||
introspect(scope: { schemas: string[]; tables: string[] }): Promise<Catalog>;
|
||||
/**
|
||||
* Reads the tables and views of the given schemas and tables, and the
|
||||
* functions of the given schemas plus the named ones (schema.name).
|
||||
*/
|
||||
introspect(scope: { schemas: string[]; tables: string[]; functions: 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. */
|
||||
|
||||
@@ -30,6 +30,8 @@ export async function readRows(db: Dialect, tx: Tx, resource: Resource, request:
|
||||
scope: (r: Resource) => Filter[], id?: string, extra: Filter[] = []): Promise<RestResponse> {
|
||||
const plan = parseRead(resource, request.query, limits);
|
||||
plan.where.push(...extra);
|
||||
const prefer = /\bcount=(exact|estimated)\b/.exec(request.headers.prefer ?? '');
|
||||
if (plan.count === 'none' && prefer) plan.count = prefer[1] as 'exact' | 'estimated';
|
||||
if (id !== undefined) {
|
||||
plan.where.unshift(keyFilter(resource, id));
|
||||
plan.limit = 1;
|
||||
|
||||
@@ -43,7 +43,8 @@ export function createREST<C = unknown>(options: RestOptions<C>): Rest<C> {
|
||||
const db = options.db;
|
||||
let loading: Promise<Api> | null = null;
|
||||
|
||||
const load = () => (loading ??= db.introspect({ schemas: options.expose.schemas ?? [], tables: options.expose.tables ?? [] })
|
||||
const functions = Object.values(options.procedures ?? {}).map((p) => p.function).filter((f) => f.includes('.'));
|
||||
const load = () => (loading ??= db.introspect({ schemas: options.expose.schemas ?? [], tables: options.expose.tables ?? [], functions })
|
||||
.then((catalog: Catalog) => ({
|
||||
resources: buildResources(catalog, options as RestOptions<unknown>),
|
||||
procedures: buildProcedures(catalog, options.procedures ?? {}),
|
||||
|
||||
@@ -18,3 +18,9 @@ test('the node listener serves under its prefix and refuses bad bodies', async (
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('Prefer: count=exact counts as count=exact does', async () => {
|
||||
const rest = createREST({ db: fakeDialect(), expose: { schemas: ['app'] } });
|
||||
const res = await rest.handle({ method: 'GET', path: '/items', query: new URLSearchParams(), headers: { prefer: 'count=exact' }, context: undefined });
|
||||
assert.equal(res.headers['content-range'], '0-0/1');
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@crudy/restloom-express",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"type": "module",
|
||||
"description": "Express adapter for restloom",
|
||||
"keywords": [
|
||||
@@ -36,7 +36,7 @@
|
||||
"prepack": "npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@crudy/restloom": "^0.1.0"
|
||||
"@crudy/restloom": "^0.2.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"express": "^5.0.0"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@crudy/restloom-fastify",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"type": "module",
|
||||
"description": "Fastify adapter for restloom",
|
||||
"keywords": [
|
||||
@@ -36,7 +36,7 @@
|
||||
"prepack": "npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@crudy/restloom": "^0.1.0"
|
||||
"@crudy/restloom": "^0.2.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"fastify": "^5.0.0"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@crudy/restloom-mssql",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"type": "module",
|
||||
"description": "SQL Server dialect for restloom",
|
||||
"keywords": [
|
||||
@@ -37,7 +37,7 @@
|
||||
"prepack": "npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@crudy/restloom": "^0.1.0"
|
||||
"@crudy/restloom": "^0.2.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"mssql": "^12.0.0"
|
||||
|
||||
@@ -9,6 +9,7 @@ SELECT o.object_id AS id, SCHEMA_NAME(o.schema_id) AS s, o.name AS n, RTRIM(o.ty
|
||||
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 ('FN', 'IF', 'TF') AND SCHEMA_NAME(o.schema_id) + '.' + o.name IN (SELECT value FROM STRING_SPLIT(@functions, ',')))
|
||||
OR (o.type IN ('U', 'V') AND SCHEMA_NAME(o.schema_id) + '.' + o.name IN (SELECT value FROM STRING_SPLIT(@tables, ','))))`;
|
||||
|
||||
const COLUMNS = `
|
||||
@@ -40,13 +41,13 @@ SELECT p.object_id AS id, SUBSTRING(p.name, 2, 200) AS name, TYPE_NAME(p.user_ty
|
||||
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> {
|
||||
export async function introspect(pool: ConnectionPool, scope: { schemas: string[]; tables: string[]; functions: 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 objects = await run(OBJECTS, { schemas: scope.schemas.join(','), tables: scope.tables.join(','), functions: scope.functions.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) => ({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@crudy/restloom-mysql",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"type": "module",
|
||||
"description": "MySQL dialect for restloom",
|
||||
"keywords": [
|
||||
@@ -36,7 +36,7 @@
|
||||
"prepack": "npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@crudy/restloom": "^0.1.0"
|
||||
"@crudy/restloom": "^0.2.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"mysql2": "^3.11.0"
|
||||
|
||||
@@ -7,9 +7,10 @@ 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> {
|
||||
export async function introspect(pool: Pool, scope: { schemas: string[]; tables: string[]; functions: string[] }): Promise<Catalog> {
|
||||
const schemas = scope.schemas.length ? scope.schemas : [''];
|
||||
const tablesIn = scope.tables.length ? scope.tables : [''];
|
||||
const functionsIn = scope.functions.length ? scope.functions : [''];
|
||||
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]),
|
||||
@@ -22,8 +23,8 @@ export async function introspect(pool: Pool, scope: { schemas: string[]; tables:
|
||||
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]),
|
||||
FROM information_schema.PARAMETERS WHERE (SPECIFIC_SCHEMA IN (?) OR CONCAT(SPECIFIC_SCHEMA, '.', SPECIFIC_NAME) IN (?)) AND ORDINAL_POSITION > 0 ORDER BY ORDINAL_POSITION`, [schemas, functionsIn]),
|
||||
q(`SELECT ROUTINE_SCHEMA AS s, ROUTINE_NAME AS n FROM information_schema.ROUTINES WHERE (ROUTINE_SCHEMA IN (?) OR CONCAT(ROUTINE_SCHEMA, '.', ROUTINE_NAME) IN (?)) AND ROUTINE_TYPE = 'FUNCTION'`, [schemas, functionsIn]),
|
||||
]);
|
||||
const byName = new Map<string, CatalogTable>();
|
||||
for (const t of tables) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@crudy/restloom-postgres",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"type": "module",
|
||||
"description": "Postgres dialect for restloom",
|
||||
"keywords": ["restloom", "postgres", "postgresql", "rest", "api"],
|
||||
@@ -18,7 +18,7 @@
|
||||
"prepack": "npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@crudy/restloom": "^0.1.0"
|
||||
"@crudy/restloom": "^0.2.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"pg": "^8.11.0"
|
||||
|
||||
@@ -45,17 +45,17 @@ SELECT n.nspname AS schema, p.proname AS name, p.proretset AS returns_set, p.pro
|
||||
(SELECT array_agg(b.typname::text || ':' || b.typtype::text || ':' || b.typcategory::text ORDER BY ord)
|
||||
FROM unnest(p.proargtypes) WITH ORDINALITY u(t, ord) JOIN pg_type b ON b.oid = u.t) AS types
|
||||
FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
|
||||
WHERE p.prokind = 'f' AND n.nspname = ANY($1::text[])`;
|
||||
WHERE p.prokind = 'f' AND (n.nspname = ANY($1::text[]) OR n.nspname || '.' || p.proname = ANY($2::text[]))`;
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
/** Reads the tables, views, keys, foreign keys and functions of the exposed schemas and tables. */
|
||||
export async function introspect(pool: Pool, scope: { schemas: string[]; tables: string[] }): Promise<Catalog> {
|
||||
export async function introspect(pool: Pool, scope: { schemas: string[]; tables: string[]; functions: string[] }): Promise<Catalog> {
|
||||
const { rows: tables } = await pool.query<Row>(TABLES, [scope.schemas, scope.tables]);
|
||||
const oids = tables.map((t) => t.oid as number);
|
||||
const [columns, keys, fks, fns] = await Promise.all([
|
||||
pool.query<Row>(COLUMNS, [oids]), pool.query<Row>(PRIMARY_KEYS, [oids]),
|
||||
pool.query<Row>(FOREIGN_KEYS, [oids]), pool.query<Row>(FUNCTIONS, [scope.schemas]),
|
||||
pool.query<Row>(FOREIGN_KEYS, [oids]), pool.query<Row>(FUNCTIONS, [scope.schemas, scope.functions]),
|
||||
]);
|
||||
const byOid = new Map<number, CatalogTable>();
|
||||
for (const t of tables) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@crudy/restloom-sqlite",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"type": "module",
|
||||
"description": "SQLite dialect for restloom",
|
||||
"keywords": [
|
||||
@@ -36,7 +36,7 @@
|
||||
"prepack": "npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@crudy/restloom": "^0.1.0"
|
||||
"@crudy/restloom": "^0.2.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"better-sqlite3": ">=12"
|
||||
|
||||
@@ -5,7 +5,7 @@ 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 {
|
||||
export function introspect(db: Database, scope: { schemas: string[]; tables: string[]; functions: 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[] = [];
|
||||
|
||||
Reference in New Issue
Block a user