feat: core grammar, discovery and renderer; Postgres dialect; conformance suite
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
# Builds every package and runs the unit tests and the conformance suite
|
||||
# against each database. The organisation only allows its own actions, so
|
||||
# checkout and Node setup are plain shell; databases are service containers.
|
||||
name: ci
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
NODE_VERSION: v24.12.0
|
||||
RESTLOOM_PG_URL: postgres://postgres@localhost:5432/postgres
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
env:
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
ports: ['5432:5432']
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U postgres" --health-interval 2s --health-timeout 2s --health-retries 30
|
||||
steps:
|
||||
- name: Check out
|
||||
run: |
|
||||
git init -q .
|
||||
git fetch -q --depth 1 "https://github.com/$GITHUB_REPOSITORY" "$GITHUB_SHA"
|
||||
git checkout -q FETCH_HEAD
|
||||
- name: Install Node
|
||||
run: |
|
||||
base="https://nodejs.org/dist/$NODE_VERSION"
|
||||
file="node-$NODE_VERSION-linux-x64.tar.xz"
|
||||
curl -fsSLO "$base/$file"
|
||||
curl -fsSL "$base/SHASUMS256.txt" | grep " $file\$" | sha256sum -c -
|
||||
mkdir -p "$RUNNER_TEMP/node"
|
||||
tar -xJf "$file" -C "$RUNNER_TEMP/node" --strip-components=1
|
||||
rm "$file"
|
||||
echo "$RUNNER_TEMP/node/bin" >> "$GITHUB_PATH"
|
||||
- run: npm ci --no-audit --no-fund
|
||||
- run: npm test
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
.DS_Store
|
||||
@@ -0,0 +1,11 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to the restloom packages are documented here. The packages are versioned together.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and the packages follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- `@crudy/restloom`: discovery from the catalog, relations from foreign keys and junction tables, the read grammar, the overridable SQL renderer, `createREST` and a Node `http` listener.
|
||||
- `@crudy/restloom-postgres`: the Postgres dialect.
|
||||
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2026 Marc Villain
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,56 @@
|
||||
# restloom
|
||||
|
||||
A REST API generated from your database's catalog. Name the schemas or tables to serve, and restloom reads their columns, types, keys and relations, and answers queries like this one:
|
||||
|
||||
```
|
||||
GET /assets?select=id,name,owner(name),tags(name)&tags.name=in.(pci,gdpr)&order=owner.name.desc
|
||||
```
|
||||
|
||||
```json
|
||||
[
|
||||
{ "id": 4, "name": "payroll db", "owner": { "name": "Linus" }, "tags": [{ "name": "pci" }, { "name": "gdpr" }] },
|
||||
{ "id": 2, "name": "mail relay", "owner": { "name": "Grace" }, "tags": [{ "name": "gdpr" }] }
|
||||
]
|
||||
```
|
||||
|
||||
Your backend stays in charge:
|
||||
- **Only what you name is served.** Nothing is exposed until a schema or table is named.
|
||||
- **Writes are opt-in.** They are off until a resource enables them.
|
||||
- **Sensitive columns stay hidden.** Columns named like passwords, hashes, tokens or keys are never served unless you show them on purpose.
|
||||
- **Clients cannot run arbitrary queries.** They choose among what you expose: there is no query language for them to run arbitrary joins with.
|
||||
|
||||
## Install
|
||||
|
||||
| Package | For |
|
||||
|---|---|
|
||||
| `@crudy/restloom` | The core: grammar, discovery, `createREST`, a Node `http` listener |
|
||||
| `@crudy/restloom-postgres` | PostgreSQL |
|
||||
|
||||
```sh
|
||||
npm install @crudy/restloom @crudy/restloom-postgres pg
|
||||
```
|
||||
|
||||
## Use
|
||||
|
||||
```ts
|
||||
import http from 'node:http';
|
||||
import pg from 'pg';
|
||||
import { createREST, nodeListener } from '@crudy/restloom';
|
||||
import { postgres } from '@crudy/restloom-postgres';
|
||||
|
||||
const rest = createREST({
|
||||
db: postgres(new pg.Pool()),
|
||||
expose: { schemas: ['app'] },
|
||||
resources: { assets: { write: true } },
|
||||
});
|
||||
|
||||
http.createServer(nodeListener(rest)).listen(3000);
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Query grammar](docs/query-grammar.md): select, filters, relations, order, pages, counts, aggregates.
|
||||
|
||||
## Licence
|
||||
|
||||
Apache-2.0.
|
||||
@@ -0,0 +1,33 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
/**
|
||||
* Every ```http block followed by a ```json block in a docs page is one
|
||||
* conformance case: the request, then the exact body it must answer. A body
|
||||
* with an `error` key must come with a 4xx or 5xx status.
|
||||
*/
|
||||
export function examples(file) {
|
||||
const text = readFileSync(file, 'utf8');
|
||||
const out = [];
|
||||
const re = /```http\n([\s\S]*?)```\s*\n+```json\n([\s\S]*?)```/g;
|
||||
for (let m; (m = re.exec(text));) {
|
||||
const line = text.slice(0, m.index).split('\n').length;
|
||||
const request = m[1].split('\n').map((l) => l.trim()).filter(Boolean).join('');
|
||||
const space = request.indexOf(' ');
|
||||
const target = new URL(request.slice(space + 1), 'http://example');
|
||||
out.push({
|
||||
name: `${file.split('/').pop()}:${line} ${request}`,
|
||||
method: request.slice(0, space),
|
||||
path: target.pathname,
|
||||
query: target.searchParams,
|
||||
expected: JSON.parse(m[2]),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function run(rest, example, assert) {
|
||||
const res = await rest.handle({ method: example.method, path: example.path, query: example.query, headers: {}, context: undefined });
|
||||
assert.deepEqual(res.body, example.expected, `status ${res.status}`);
|
||||
if (example.expected && typeof example.expected === 'object' && 'error' in example.expected) assert.ok(res.status >= 400, `status ${res.status}`);
|
||||
else assert.ok(res.status < 300, `status ${res.status}`);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
DROP SCHEMA IF EXISTS demo CASCADE;
|
||||
CREATE SCHEMA demo;
|
||||
|
||||
CREATE TABLE demo.users (
|
||||
id integer PRIMARY KEY,
|
||||
name text NOT NULL,
|
||||
email text,
|
||||
password_hash text,
|
||||
"limit" integer,
|
||||
manager_id integer REFERENCES demo.users (id)
|
||||
);
|
||||
|
||||
CREATE TABLE demo.assets (
|
||||
id integer PRIMARY KEY,
|
||||
name text NOT NULL,
|
||||
status text NOT NULL,
|
||||
owner_id integer REFERENCES demo.users (id),
|
||||
cost numeric(10, 2),
|
||||
active boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz 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 demo.assets (id),
|
||||
tag_id integer NOT NULL REFERENCES demo.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 demo.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, true, '2026-01-02T10:00:00Z'),
|
||||
(2, 'mail relay', 'open', 2, 300.00, true, '2026-01-05T09:30:00Z'),
|
||||
(3, 'old wiki', 'retired', NULL, NULL, false, '2025-06-01T00:00:00Z'),
|
||||
(4, 'payroll db', 'open', 3, 4000.00, true, '2026-02-11T16:45:00Z');
|
||||
|
||||
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,29 @@
|
||||
import { 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 { postgres } from '../packages/postgres/dist/index.js';
|
||||
import { examples, run } from './examples.mjs';
|
||||
|
||||
const require = createRequire(new URL('../packages/postgres/package.json', import.meta.url));
|
||||
const { Pool } = require('pg');
|
||||
const url = process.env.RESTLOOM_PG_URL;
|
||||
const docs = new URL('../docs/', import.meta.url).pathname;
|
||||
const pages = ['query-grammar.md'];
|
||||
|
||||
let pool;
|
||||
let rest;
|
||||
before(async () => {
|
||||
if (!url) return;
|
||||
pool = new Pool({ connectionString: url });
|
||||
await pool.query(readFileSync(new URL('./fixtures/postgres.sql', import.meta.url), 'utf8'));
|
||||
rest = createREST({ db: postgres(pool), expose: { schemas: ['demo'] }, resources: { asset_tags: { expose: false } } });
|
||||
});
|
||||
after(async () => { await pool?.end(); });
|
||||
|
||||
for (const page of pages) {
|
||||
for (const example of examples(docs + page)) {
|
||||
test(`postgres ${example.name}`, { skip: !url && 'RESTLOOM_PG_URL is not set' }, () => run(rest, example, assert));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
# Query grammar
|
||||
|
||||
A read is a GET with a query string. Every example on this page runs against the [demo schema](../conformance/fixtures/postgres.sql) in the test suite, against every database, so the answers shown are the answers you get.
|
||||
|
||||
The demo schema has four resources:
|
||||
|
||||
| Resource | Columns | Relations |
|
||||
|---|---|---|
|
||||
| `users` | id, name, email, limit, manager_id | `manager` (one user), `users` (the users they manage), `assets` |
|
||||
| `assets` | id, name, status, owner_id, cost, active, created_at | `owner` (one user), `tags` (many, through `asset_tags`), `incidents` (many) |
|
||||
| `tags` | id, name | `assets` (many, through `asset_tags`) |
|
||||
| `incidents` | id, title, asset_id, severity, opened_on | `asset` (one asset) |
|
||||
|
||||
`users.password_hash` exists in the table but is never served: its name looks sensitive, so it is hidden.
|
||||
|
||||
Examples are shown unencoded. A client encodes them like any query string: `curl -g` sends them as written.
|
||||
|
||||
## Choosing columns: `select`
|
||||
|
||||
Without `select`, a row has every visible column. With it, only the listed ones, in that order.
|
||||
|
||||
```http
|
||||
GET /assets?select=id,name&status=eq.open&order=id
|
||||
```
|
||||
|
||||
```json
|
||||
[{ "id": 1, "name": "vpn gateway" }, { "id": 2, "name": "mail relay" }, { "id": 4, "name": "payroll db" }]
|
||||
```
|
||||
|
||||
One row is read by its key. It answers 404 when the row does not exist or is not visible.
|
||||
|
||||
```http
|
||||
GET /assets/1?select=id,name,owner(name)
|
||||
```
|
||||
|
||||
```json
|
||||
{ "id": 1, "name": "vpn gateway", "owner": { "name": "Ada" } }
|
||||
```
|
||||
|
||||
## Related rows: embedding
|
||||
|
||||
`relation(columns)` puts related rows inside each row: an object (or `null`) for a to-one relation, an array for a to-many one. `relation(*)` takes every visible column. Embeds nest up to the depth limit (2 by default).
|
||||
|
||||
```http
|
||||
GET /assets?status=eq.open&select=id,owner(name,manager(name))
|
||||
```
|
||||
|
||||
```json
|
||||
[
|
||||
{ "id": 1, "owner": { "name": "Ada", "manager": null } },
|
||||
{ "id": 2, "owner": { "name": "Grace", "manager": { "name": "Ada" } } },
|
||||
{ "id": 4, "owner": { "name": "Linus", "manager": { "name": "Ada" } } }
|
||||
]
|
||||
```
|
||||
|
||||
A relation of a table to itself works the same way. Here `users` is the list of people each user manages; rename it with a [relation override](./discovery.md#relations) if `reports` reads better.
|
||||
|
||||
```http
|
||||
GET /users?select=id,name,manager(name),users(name)
|
||||
```
|
||||
|
||||
```json
|
||||
[
|
||||
{ "id": 1, "name": "Ada", "manager": null, "users": [{ "name": "Grace" }, { "name": "Linus" }] },
|
||||
{ "id": 2, "name": "Grace", "manager": { "name": "Ada" }, "users": [] },
|
||||
{ "id": 3, "name": "Linus", "manager": { "name": "Ada" }, "users": [] }
|
||||
]
|
||||
```
|
||||
|
||||
An embedded array is ordered by the related key, unless `relation.order` says otherwise. `relation.limit` and `relation.offset` page it. Without a limit it holds at most `maxPage` rows (1000).
|
||||
|
||||
```http
|
||||
GET /assets?select=id,incidents(title)&incidents.order=severity.desc&incidents.limit=1&order=incidents.count.desc
|
||||
```
|
||||
|
||||
```json
|
||||
[
|
||||
{ "id": 1, "incidents": [{ "title": "Brute force" }] },
|
||||
{ "id": 2, "incidents": [{ "title": "Spoofed mail" }] },
|
||||
{ "id": 3, "incidents": [] },
|
||||
{ "id": 4, "incidents": [] }
|
||||
]
|
||||
```
|
||||
|
||||
## Filtering
|
||||
|
||||
A filter is `column=operator.value`. Every filter must hold.
|
||||
|
||||
| Operator | Means | Applies to |
|
||||
|---|---|---|
|
||||
| `eq`, `neq` | equal, not equal | any comparable column |
|
||||
| `gt`, `gte`, `lt`, `lte` | greater or less than | text, numbers, dates and times |
|
||||
| `in.(a,b,c)` | one of the list; quote an item holding a comma: `in.("a,b",c)` | any comparable column |
|
||||
| `like`, `ilike` | pattern, `*` for any text; `ilike` ignores case | text |
|
||||
| `is.null`, `is.true`, `is.false` | null, or a boolean value | `null` any column, `true` and `false` booleans |
|
||||
|
||||
`not.` before the operator negates it: `status=not.in.(retired,lost)`.
|
||||
|
||||
A value is checked against the column's type before any SQL runs: `id=eq.abc` answers `invalid_value`. An operator that does not fit the column is refused:
|
||||
|
||||
```http
|
||||
GET /assets?cost=like.1*
|
||||
```
|
||||
|
||||
```json
|
||||
{ "error": "bad_operator", "message": "like on cost" }
|
||||
```
|
||||
|
||||
### Through relations
|
||||
|
||||
A filter can name a column of a related resource: `relation.column=operator.value`.
|
||||
|
||||
- **To-one** (`owner.name`): the related row must match.
|
||||
- **To-many** and **many-to-many** (`tags.name`): at least one related row must match.
|
||||
- **With `not.`** on a relation path: no related row may match.
|
||||
|
||||
```http
|
||||
GET /assets?select=id,name&owner.name=ilike.*a*
|
||||
```
|
||||
|
||||
```json
|
||||
[{ "id": 1, "name": "vpn gateway" }, { "id": 2, "name": "mail relay" }]
|
||||
```
|
||||
|
||||
```http
|
||||
GET /assets?select=id,name,owner(name),tags(name)&tags.name=in.(pci,gdpr)&order=owner.name.desc
|
||||
```
|
||||
|
||||
```json
|
||||
[
|
||||
{ "id": 4, "name": "payroll db", "owner": { "name": "Linus" }, "tags": [{ "name": "pci" }, { "name": "gdpr" }] },
|
||||
{ "id": 2, "name": "mail relay", "owner": { "name": "Grace" }, "tags": [{ "name": "gdpr" }] },
|
||||
{ "id": 1, "name": "vpn gateway", "owner": { "name": "Ada" }, "tags": [{ "name": "pci" }, { "name": "internal" }] }
|
||||
]
|
||||
```
|
||||
|
||||
Assets with no `pci` tag, including assets with no tag at all:
|
||||
|
||||
```http
|
||||
GET /assets?select=id&tags.name=not.eq.pci
|
||||
```
|
||||
|
||||
```json
|
||||
[{ "id": 2 }, { "id": 3 }]
|
||||
```
|
||||
|
||||
`relation=is.empty` keeps rows with no related row, `relation=not.is.empty` rows with at least one. `is.null` means the same.
|
||||
|
||||
```http
|
||||
GET /assets?select=id&tags=is.empty
|
||||
```
|
||||
|
||||
```json
|
||||
[{ "id": 3 }]
|
||||
```
|
||||
|
||||
### Either-or: `or` and `and`
|
||||
|
||||
`or=(term,term)` holds when one term does. A term is `path.operator.value`, with the same operators and relation paths. `and=(...)` groups terms inside an `or`, one level deep. `not.or` and `not.and` negate the group.
|
||||
|
||||
```http
|
||||
GET /assets?select=id&or=(status.eq.retired,owner.name.eq.Linus)
|
||||
```
|
||||
|
||||
```json
|
||||
[{ "id": 3 }, { "id": 4 }]
|
||||
```
|
||||
|
||||
## Ordering: `order`
|
||||
|
||||
`order=column.direction,...` with `asc` (default) or `desc`, and optionally `nullsfirst` or `nullslast`.
|
||||
|
||||
- **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.
|
||||
|
||||
## Pages and counts
|
||||
|
||||
`limit` (default 50) and `offset` page a read. A limit over 1000 is refused, never silently reduced:
|
||||
|
||||
```http
|
||||
GET /assets?limit=1001
|
||||
```
|
||||
|
||||
```json
|
||||
{ "error": "limit_exceeded", "message": "1001" }
|
||||
```
|
||||
|
||||
Every list answers a `Content-Range` header: `0-49/*`, or `*/0` for an empty page. `count=exact` replaces `*` with the total, and `count=estimated` with the planner's estimate where the database has one.
|
||||
|
||||
```http
|
||||
GET /assets?select=id&limit=2&offset=1&count=exact
|
||||
```
|
||||
|
||||
```json
|
||||
[{ "id": 2 }, { "id": 3 }]
|
||||
```
|
||||
|
||||
The answer above comes with `Content-Range: 1-2/4`.
|
||||
|
||||
## Totals: aggregates
|
||||
|
||||
`count()`, and `column.sum()`, `.avg()`, `.min()`, `.max()`, group by every plain column selected. `sum` and `avg` take numbers; `min` and `max` anything orderable. An aggregate read cannot embed relations, and orders by its selected columns only.
|
||||
|
||||
```http
|
||||
GET /assets?select=status,count(),cost.sum()&order=status
|
||||
```
|
||||
|
||||
```json
|
||||
[{ "status": "open", "count": 3, "sum": 5500.5 }, { "status": "retired", "count": 1, "sum": null }]
|
||||
```
|
||||
|
||||
## Names that are keywords
|
||||
|
||||
`select`, `order`, `limit`, `offset`, `count`, `or` and `and` are reserved, and so are `order`, `limit` and `offset` after a relation name. A double-quoted name is always a column, never a keyword, wherever it appears: `"limit"=gte.2`, `select="order"`, `order="limit".desc`, `tags."order"=eq.x`.
|
||||
|
||||
```http
|
||||
GET /users?select=id,"limit"&"limit"=gte.2
|
||||
```
|
||||
|
||||
```json
|
||||
[{ "id": 1, "limit": 5 }, { "id": 3, "limit": 2 }]
|
||||
```
|
||||
|
||||
## What is never served
|
||||
|
||||
A column that is hidden answers exactly as a column that does not exist, so a client cannot learn it is there:
|
||||
|
||||
```http
|
||||
GET /users?select=password_hash
|
||||
```
|
||||
|
||||
```json
|
||||
{ "error": "unknown_column", "message": "password_hash" }
|
||||
```
|
||||
|
||||
Every error code is listed in [errors](./errors.md).
|
||||
Generated
+260
@@ -0,0 +1,260 @@
|
||||
{
|
||||
"name": "restloom-workspace",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "restloom-workspace",
|
||||
"workspaces": [
|
||||
"packages/core",
|
||||
"packages/postgres"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@crudy/restloom": {
|
||||
"resolved": "packages/core",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@crudy/restloom-postgres": {
|
||||
"resolved": "packages/postgres",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.19.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz",
|
||||
"integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": ">=7.24.0 <7.24.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/pg": {
|
||||
"version": "8.23.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz",
|
||||
"integrity": "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
"pg-protocol": "*",
|
||||
"pg-types": "^2.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg": {
|
||||
"version": "8.23.1",
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz",
|
||||
"integrity": "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.14.1",
|
||||
"pg-pool": "^3.14.0",
|
||||
"pg-protocol": "^1.16.1",
|
||||
"pg-types": "2.2.0",
|
||||
"pgpass": "1.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"pg-cloudflare": "^1.4.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"pg-native": ">=3.0.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"pg-native": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/pg-cloudflare": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz",
|
||||
"integrity": "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/pg-connection-string": {
|
||||
"version": "2.14.1",
|
||||
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.1.tgz",
|
||||
"integrity": "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-int8": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
|
||||
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-pool": {
|
||||
"version": "3.14.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
|
||||
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"pg": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-protocol": {
|
||||
"version": "1.16.1",
|
||||
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.1.tgz",
|
||||
"integrity": "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-types": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
|
||||
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pg-int8": "1.0.1",
|
||||
"postgres-array": "~2.0.0",
|
||||
"postgres-bytea": "~1.0.0",
|
||||
"postgres-date": "~1.0.4",
|
||||
"postgres-interval": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/pgpass": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
|
||||
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"split2": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-array": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-bytea": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
|
||||
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-date": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
|
||||
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-interval": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
|
||||
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"xtend": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/split2": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">= 10.x"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.24.6",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
|
||||
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4"
|
||||
}
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@crudy/restloom",
|
||||
"version": "0.1.0",
|
||||
"license": "Apache-2.0",
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.0.0",
|
||||
"typescript": "^5.9.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"packages/postgres": {
|
||||
"name": "@crudy/restloom-postgres",
|
||||
"version": "0.1.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@crudy/restloom": "^0.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/pg": "^8.11.0",
|
||||
"pg": "^8.16.0",
|
||||
"typescript": "^5.9.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"pg": "^8.11.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "restloom-workspace",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"workspaces": [
|
||||
"packages/core",
|
||||
"packages/postgres"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"scripts": {
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2026 Marc Villain
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "@crudy/restloom",
|
||||
"version": "0.1.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"],
|
||||
"author": "Marc Villain",
|
||||
"homepage": "https://restloom.crudy.fr",
|
||||
"repository": { "type": "git", "url": "git+https://github.com/crudylabs/restloom.git", "directory": "packages/core" },
|
||||
"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"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.0.0",
|
||||
"typescript": "^5.9.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* What a dialect reads from its database's catalog. The core never queries the
|
||||
* catalog itself: every dialect maps its own types onto these logical ones, and
|
||||
* operators, aggregates and value checks are decided on the logical type.
|
||||
*/
|
||||
export type LogicalType =
|
||||
| 'text' | 'integer' | 'number' | 'boolean' | 'uuid'
|
||||
| 'date' | 'timestamp' | 'time' | 'json' | 'binary' | 'array' | 'other';
|
||||
|
||||
export interface CatalogColumn {
|
||||
name: string;
|
||||
type: LogicalType;
|
||||
/** The database's own type name, kept for error messages and dialect use. */
|
||||
nativeType: string;
|
||||
nullable: boolean;
|
||||
/** Computed by the database: never writable. */
|
||||
generated: boolean;
|
||||
}
|
||||
|
||||
export interface CatalogForeignKey {
|
||||
columns: string[];
|
||||
target: { schema: string; table: string };
|
||||
targetColumns: string[];
|
||||
}
|
||||
|
||||
export interface CatalogTable {
|
||||
schema: string;
|
||||
name: string;
|
||||
kind: 'table' | 'view';
|
||||
columns: CatalogColumn[];
|
||||
primaryKey: string[];
|
||||
foreignKeys: CatalogForeignKey[];
|
||||
}
|
||||
|
||||
export interface CatalogFunction {
|
||||
schema: string;
|
||||
name: string;
|
||||
args: { name: string; type: LogicalType; nativeType: string; hasDefault: boolean }[];
|
||||
returnsSet: boolean;
|
||||
}
|
||||
|
||||
export interface Catalog {
|
||||
tables: CatalogTable[];
|
||||
functions: CatalogFunction[];
|
||||
}
|
||||
|
||||
export function qualified(t: { schema: string; name: string } | { schema: string; table: string }): string {
|
||||
return `${t.schema}.${'name' in t ? t.name : t.table}`;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { Catalog } from './catalog.js';
|
||||
import type { RestError } from './errors.js';
|
||||
import type { SqlRenderer } from './sql/renderer.js';
|
||||
|
||||
/** A connection inside a transaction. Rows come back as plain objects. */
|
||||
export interface Tx {
|
||||
query(sql: string, params: unknown[]): Promise<Record<string, unknown>[]>;
|
||||
}
|
||||
|
||||
/** What a database can do natively. A request needing something missing is refused with `unsupported`. */
|
||||
export interface Capabilities {
|
||||
/** INSERT/UPDATE/DELETE return the written rows in the same statement. */
|
||||
returning: boolean;
|
||||
/** A planner estimate for count=estimated. */
|
||||
estimatedCount: boolean;
|
||||
/** Row security enforced by the database for the session's role. */
|
||||
rowSecurity: boolean;
|
||||
}
|
||||
|
||||
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>;
|
||||
/** 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>;
|
||||
/** 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. */
|
||||
estimate?(tx: Tx, sql: string, params: unknown[]): Promise<number | null>;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Every refusal restloom answers. The code is stable and documented; the detail
|
||||
* names the part of the request at fault and never carries database text.
|
||||
*/
|
||||
export class RestError extends Error {
|
||||
constructor(public readonly status: number, public readonly code: string, public readonly detail?: string) {
|
||||
super(detail ? `${code}: ${detail}` : code);
|
||||
}
|
||||
}
|
||||
|
||||
export const badRequest = (code: string, detail?: string) => new RestError(400, code, detail);
|
||||
export const notFound = (detail?: string) => new RestError(404, 'not_found', detail);
|
||||
@@ -0,0 +1,83 @@
|
||||
import { badRequest } from '../errors.js';
|
||||
import type { Limits } from '../options.js';
|
||||
import { OPERATORS, type Filter, type Operator, type Value } from '../plan.js';
|
||||
import type { Resource } from '../resources.js';
|
||||
import { headAndRest, segments, type Segment } from './names.js';
|
||||
import { column, walk } from './resolve.js';
|
||||
import { listItems, splitTopLevel, unquote } from './split.js';
|
||||
import { likePattern, operatorFits, typed } from './values.js';
|
||||
|
||||
interface Operation { op: Operator; negate: boolean; raw: string }
|
||||
|
||||
function operation(expr: string): Operation {
|
||||
let rest = expr;
|
||||
let negate = false;
|
||||
if (rest.startsWith('not.')) { negate = true; rest = rest.slice(4); }
|
||||
const dot = rest.indexOf('.');
|
||||
const op = (dot < 0 ? rest : rest.slice(0, dot)) as Operator;
|
||||
if (!OPERATORS.includes(op)) throw badRequest('unknown_operator', op);
|
||||
if (dot < 0) throw badRequest('bad_filter', expr);
|
||||
return { op, negate, raw: rest.slice(dot + 1) };
|
||||
}
|
||||
|
||||
/** One filter from its path segments and its `[not.]op.value`. */
|
||||
function condition(resource: Resource, segs: Segment[], expr: string, limits: Limits): Filter {
|
||||
const { op, negate, raw } = operation(expr);
|
||||
const { relations, resource: owner, last } = walk(resource, segs, limits.depth);
|
||||
const asRelation = !last.quoted ? owner.relations.get(last.name) : undefined;
|
||||
if (asRelation) {
|
||||
if (op !== 'is' || (raw !== 'empty' && raw !== 'null')) throw badRequest('bad_filter', expr);
|
||||
if (relations.length + 1 > limits.depth) throw badRequest('too_deep', segs.map((s) => s.name).join('.'));
|
||||
return { kind: 'present', relations: [...relations, asRelation], negate: !negate };
|
||||
}
|
||||
const col = column(owner, last.name);
|
||||
if (!operatorFits(op, col.type, raw)) throw badRequest('bad_operator', `${op} on ${col.name}`);
|
||||
let value: Value | Value[];
|
||||
if (op === 'in') {
|
||||
const items = listItems(raw);
|
||||
if (items.length === 0 || items.length > limits.inList) throw badRequest('bad_list', col.name);
|
||||
value = items.map((i) => typed(i, col.type, col.name));
|
||||
} else if (op === 'is') {
|
||||
value = raw === 'null' ? null : raw === 'true';
|
||||
} else if (op === 'like' || op === 'ilike') {
|
||||
value = likePattern(unquote(raw));
|
||||
} else {
|
||||
value = typed(unquote(raw), col.type, col.name);
|
||||
}
|
||||
return { kind: 'cmp', path: { relations, column: col.name }, op, negate, value };
|
||||
}
|
||||
|
||||
/** A term of or/and: `path.op.value`, `path.not.op.value`, or a nested and(...)/or(...). */
|
||||
function term(resource: Resource, expr: string, limits: Limits, depth: number): Filter {
|
||||
const nested = /^(not\.)?(and|or)(\(.*\))$/s.exec(expr);
|
||||
if (nested) {
|
||||
if (depth >= 1) throw badRequest('too_deep', expr);
|
||||
return logic(resource, nested[2] as 'and' | 'or', nested[3], !!nested[1], limits, depth + 1);
|
||||
}
|
||||
const path: string[] = [];
|
||||
let rest: string | null = expr;
|
||||
while (rest !== null) {
|
||||
const [head, tail]: [string, string | null] = headAndRest(rest);
|
||||
if (!head.startsWith('"') && (head === 'not' || (OPERATORS as readonly string[]).includes(head)) && path.length > 0) {
|
||||
return condition(resource, segments(path.join('.')), rest, limits);
|
||||
}
|
||||
path.push(head);
|
||||
rest = tail;
|
||||
}
|
||||
throw badRequest('bad_filter', expr);
|
||||
}
|
||||
|
||||
export function logic(resource: Resource, kind: 'and' | 'or', value: string, negate: boolean, limits: Limits, depth = 0): Filter {
|
||||
if (!value.startsWith('(') || !value.endsWith(')')) throw badRequest('bad_logic', value);
|
||||
const terms = splitTopLevel(value.slice(1, -1)).map((t) => term(resource, t.trim(), limits, depth));
|
||||
if (terms.length === 0) throw badRequest('bad_logic', value);
|
||||
return { kind, negate, terms };
|
||||
}
|
||||
|
||||
export function filter(resource: Resource, key: string, value: string, limits: Limits): Filter {
|
||||
return condition(resource, segments(key), value, limits);
|
||||
}
|
||||
|
||||
export function countTerms(f: Filter): number {
|
||||
return f.kind === 'or' || f.kind === 'and' ? f.terms.reduce((n, t) => n + countTerms(t), 0) : 1;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { badRequest } from '../errors.js';
|
||||
|
||||
/** One part of a dotted name. A quoted part is always a column, never a keyword. */
|
||||
export interface Segment { name: string; quoted: boolean }
|
||||
|
||||
/**
|
||||
* Reads `owner."order".name` into its segments. Quotes let a name hold a dot
|
||||
* or a keyword; a backslash escapes a quote inside them.
|
||||
*/
|
||||
export function segments(input: string): Segment[] {
|
||||
const out: Segment[] = [];
|
||||
let i = 0;
|
||||
while (i <= input.length) {
|
||||
if (input[i] === '"') {
|
||||
let name = '';
|
||||
i++;
|
||||
while (i < input.length && input[i] !== '"') {
|
||||
if (input[i] === '\\') i++;
|
||||
name += input[i++] ?? '';
|
||||
}
|
||||
if (input[i] !== '"') throw badRequest('bad_name', input);
|
||||
i++;
|
||||
out.push({ name, quoted: true });
|
||||
} else {
|
||||
const end = input.indexOf('.', i);
|
||||
const name = input.slice(i, end < 0 ? input.length : end);
|
||||
out.push({ name, quoted: false });
|
||||
i = end < 0 ? input.length : end;
|
||||
}
|
||||
if (i >= input.length) break;
|
||||
if (input[i] !== '.') throw badRequest('bad_name', input);
|
||||
i++;
|
||||
if (i === input.length) throw badRequest('bad_name', input);
|
||||
}
|
||||
if (out.some((s) => s.name === '')) throw badRequest('bad_name', input);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Splits `name.rest` at the first dot outside quotes. */
|
||||
export function headAndRest(input: string): [string, string | null] {
|
||||
let quoted = false;
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
if (input[i] === '\\' && quoted) { i++; continue; }
|
||||
if (input[i] === '"') quoted = !quoted;
|
||||
else if (!quoted && input[i] === '.') return [input.slice(0, i), input.slice(i + 1)];
|
||||
}
|
||||
return [input, null];
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { badRequest } from '../errors.js';
|
||||
import type { Limits } from '../options.js';
|
||||
import type { OrderTerm, Selection } from '../plan.js';
|
||||
import type { Resource } from '../resources.js';
|
||||
import { segments } from './names.js';
|
||||
import { column, walk } from './resolve.js';
|
||||
import { splitTopLevel } from './split.js';
|
||||
import { orderable } from './values.js';
|
||||
|
||||
const MODIFIERS = new Set(['asc', 'desc', 'nullsfirst', 'nullslast']);
|
||||
|
||||
function orderTerm(resource: Resource, raw: string, limits: Limits): OrderTerm {
|
||||
const segs = segments(raw.trim());
|
||||
const term: Omit<OrderTerm, 'key'> = { desc: false };
|
||||
while (segs.length > 1 && !segs[segs.length - 1].quoted && MODIFIERS.has(segs[segs.length - 1].name)) {
|
||||
const m = segs.pop()!.name;
|
||||
if (m === 'asc' || m === 'desc') term.desc = m === 'desc';
|
||||
else term.nulls = m === 'nullsfirst' ? 'first' : 'last';
|
||||
}
|
||||
const last = segs[segs.length - 1];
|
||||
if (segs.length > 1 && !last.quoted && last.name === 'count') {
|
||||
const { relations } = walk(resource, [...segs.slice(0, -1), last], limits.depth);
|
||||
const rel = relations[relations.length - 1];
|
||||
if (rel.kind !== 'many' || relations.slice(0, -1).some((r) => r.kind !== 'one')) throw badRequest('bad_order', raw);
|
||||
return { key: { kind: 'count', relations }, ...term };
|
||||
}
|
||||
const { relations, resource: owner } = walk(resource, segs, limits.depth);
|
||||
if (relations.some((r) => r.kind !== 'one')) throw badRequest('bad_order', raw);
|
||||
const col = column(owner, last.name);
|
||||
if (!orderable(col.type)) throw badRequest('bad_order', raw);
|
||||
return { key: { kind: 'column', path: { relations, column: col.name } }, ...term };
|
||||
}
|
||||
|
||||
/**
|
||||
* The order of a read. A grouped read orders by its selected plain columns
|
||||
* only. Any other read gets the key appended, so pages never shift.
|
||||
*/
|
||||
export function ordering(resource: Resource, raw: string | null, select: Selection, limits: Limits): OrderTerm[] {
|
||||
const terms = (raw ? splitTopLevel(raw) : []).filter((t) => t.trim() !== '').map((t) => orderTerm(resource, t, limits));
|
||||
if (select.aggregates.length > 0) {
|
||||
for (const t of terms) {
|
||||
if (t.key.kind !== 'column' || t.key.path.relations.length > 0 || !select.columns.includes(t.key.path.column)) throw badRequest('bad_order', 'a grouped read orders by its selected columns');
|
||||
}
|
||||
return terms;
|
||||
}
|
||||
const direct = new Set(terms.flatMap((t) => (t.key.kind === 'column' && t.key.path.relations.length === 0 ? [t.key.path.column] : [])));
|
||||
for (const k of resource.key) {
|
||||
if (!direct.has(k)) terms.push({ key: { kind: 'column', path: { relations: [], column: k } }, desc: false });
|
||||
}
|
||||
return terms;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { badRequest } from '../errors.js';
|
||||
import type { Limits } from '../options.js';
|
||||
import type { Embed, Filter, ReadPlan, Selection } from '../plan.js';
|
||||
import type { Resource } from '../resources.js';
|
||||
import { countTerms, filter, logic } from './filter.js';
|
||||
import { segments } from './names.js';
|
||||
import { ordering } from './order.js';
|
||||
import { selection } from './select.js';
|
||||
|
||||
const RESERVED = new Set(['select', 'order', 'limit', 'offset', 'count', 'or', 'and', 'not.or', 'not.and']);
|
||||
const EMBED_MODIFIERS = new Set(['order', 'limit', 'offset']);
|
||||
|
||||
export function bounded(raw: string | null, fallback: number, max: number, code: string): number {
|
||||
if (raw === null) return fallback;
|
||||
if (!/^\d{1,15}$/.test(raw)) throw badRequest(code, raw);
|
||||
const n = Number(raw);
|
||||
if (n > max) throw badRequest(code === 'bad_limit' ? 'limit_exceeded' : code, raw);
|
||||
return n;
|
||||
}
|
||||
|
||||
function embedAt(select: Selection, names: string[]): Embed {
|
||||
let current = select;
|
||||
let found: Embed | undefined;
|
||||
for (const name of names) {
|
||||
found = current.embeds.find((e) => e.name === name);
|
||||
if (!found) throw badRequest('not_embedded', names.join('.'));
|
||||
current = found.select;
|
||||
}
|
||||
return found!;
|
||||
}
|
||||
|
||||
/** `rel.order`, `rel.limit`, `rel.offset`: the last segment is an unquoted modifier after at least one embed name. */
|
||||
function embedModifier(key: string): { path: string[]; modifier: string } | null {
|
||||
const segs = segments(key);
|
||||
const last = segs[segs.length - 1];
|
||||
if (segs.length < 2 || last.quoted || !EMBED_MODIFIERS.has(last.name)) return null;
|
||||
return { path: segs.slice(0, -1).map((s) => s.name), modifier: last.name };
|
||||
}
|
||||
|
||||
/** An embedded array without a limit holds at most a page of the largest size. */
|
||||
function capEmbeds(select: Selection, limits: Limits): void {
|
||||
for (const e of select.embeds) {
|
||||
if (e.relation.kind === 'many') e.limit ??= limits.maxPage;
|
||||
capEmbeds(e.select, limits);
|
||||
}
|
||||
}
|
||||
|
||||
/** Reads a query string into a read plan. Every name is checked against the resource; nothing unknown reaches SQL. */
|
||||
export function parseRead(resource: Resource, query: URLSearchParams, limits: Limits): ReadPlan {
|
||||
const select = selection(resource, query.get('select'), limits);
|
||||
const where: Filter[] = [];
|
||||
for (const [key, value] of query) {
|
||||
if (RESERVED.has(key)) {
|
||||
if (key === 'or' || key === 'and') where.push(logic(resource, key, value, false, limits));
|
||||
if (key === 'not.or' || key === 'not.and') where.push(logic(resource, key.slice(4) as 'or' | 'and', value, true, limits));
|
||||
continue;
|
||||
}
|
||||
const mod = embedModifier(key);
|
||||
if (mod) {
|
||||
const embed = embedAt(select, mod.path);
|
||||
if (mod.modifier === 'order') embed.order = ordering(embed.relation.target, value, embed.select, limits);
|
||||
else if (mod.modifier === 'limit') embed.limit = bounded(value, limits.page, limits.maxPage, 'bad_limit');
|
||||
else embed.offset = bounded(value, 0, Number.MAX_SAFE_INTEGER, 'bad_offset');
|
||||
continue;
|
||||
}
|
||||
where.push(filter(resource, key, value, limits));
|
||||
}
|
||||
capEmbeds(select, limits);
|
||||
if (where.reduce((n, f) => n + countTerms(f), 0) > limits.filters) throw badRequest('too_many_filters');
|
||||
const count = query.get('count');
|
||||
if (count !== null && count !== 'exact' && count !== 'estimated') throw badRequest('bad_count', count);
|
||||
return {
|
||||
resource,
|
||||
select,
|
||||
where,
|
||||
order: ordering(resource, query.get('order'), select, limits),
|
||||
limit: bounded(query.get('limit'), limits.page, limits.maxPage, 'bad_limit'),
|
||||
offset: bounded(query.get('offset'), 0, Number.MAX_SAFE_INTEGER, 'bad_offset'),
|
||||
count: count ?? 'none',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { badRequest } from '../errors.js';
|
||||
import type { Column, Relation, Resource } from '../resources.js';
|
||||
import type { Segment } from './names.js';
|
||||
|
||||
export interface Resolved { relations: Relation[]; resource: Resource; last: Segment }
|
||||
|
||||
/**
|
||||
* Walks the leading segments as relations and returns the resource the last
|
||||
* segment is read on. A quoted segment is a column, so it cannot be a hop.
|
||||
*/
|
||||
export function walk(resource: Resource, segs: Segment[], depth: number): Resolved {
|
||||
const relations: Relation[] = [];
|
||||
let current = resource;
|
||||
for (const seg of segs.slice(0, -1)) {
|
||||
const rel = seg.quoted ? undefined : current.relations.get(seg.name);
|
||||
if (!rel) throw badRequest('unknown_relation', seg.name);
|
||||
relations.push(rel);
|
||||
current = rel.target;
|
||||
}
|
||||
if (relations.length > depth) throw badRequest('too_deep', segs.map((s) => s.name).join('.'));
|
||||
return { relations, resource: current, last: segs[segs.length - 1] };
|
||||
}
|
||||
|
||||
export function column(resource: Resource, name: string): Column {
|
||||
const c = resource.columns.get(name);
|
||||
if (!c) throw badRequest('unknown_column', name);
|
||||
return c;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { badRequest } from '../errors.js';
|
||||
import type { Limits } from '../options.js';
|
||||
import { AGGREGATES, type Aggregate, type Selection } from '../plan.js';
|
||||
import type { Resource } from '../resources.js';
|
||||
import { segments } from './names.js';
|
||||
import { column } from './resolve.js';
|
||||
import { splitTopLevel } from './split.js';
|
||||
import { orderable } from './values.js';
|
||||
|
||||
function single(raw: string): string {
|
||||
const segs = segments(raw.trim());
|
||||
if (segs.length !== 1) throw badRequest('bad_select', raw);
|
||||
return segs[0].name;
|
||||
}
|
||||
|
||||
function all(resource: Resource): string[] {
|
||||
return [...resource.columns.keys()];
|
||||
}
|
||||
|
||||
/**
|
||||
* A select list: columns, `*`, `count()`, `col.sum()` and the other
|
||||
* aggregates, and `relation(list)` embeds, nested up to the depth limit.
|
||||
*/
|
||||
export function selection(resource: Resource, raw: string | null, limits: Limits, level = 0): Selection {
|
||||
const out: Selection = { columns: [], aggregates: [], embeds: [] };
|
||||
if (raw === null || raw.trim() === '' || raw.trim() === '*') return { ...out, columns: all(resource) };
|
||||
const seen = new Set<string>();
|
||||
const claim = (name: string) => {
|
||||
if (seen.has(name)) throw badRequest('duplicate_select', name);
|
||||
seen.add(name);
|
||||
};
|
||||
for (const part of splitTopLevel(raw).map((p) => p.trim())) {
|
||||
if (part === '*') { all(resource).forEach((c) => { claim(c); out.columns.push(c); }); continue; }
|
||||
if (part === 'count()') { claim('count'); out.aggregates.push({ fn: 'count', column: null }); continue; }
|
||||
const agg = /^(.+)\.([a-z]+)\(\)$/s.exec(part);
|
||||
if (agg) {
|
||||
const fn = agg[2] as Aggregate;
|
||||
if (!AGGREGATES.includes(fn) || fn === 'count') throw badRequest('unknown_aggregate', agg[2]);
|
||||
const col = column(resource, single(agg[1]));
|
||||
const fits = fn === 'sum' || fn === 'avg' ? col.type === 'integer' || col.type === 'number' : orderable(col.type);
|
||||
if (!fits) throw badRequest('bad_aggregate', part);
|
||||
claim(fn);
|
||||
out.aggregates.push({ fn, column: col.name });
|
||||
continue;
|
||||
}
|
||||
const embed = /^([A-Za-z_][A-Za-z0-9_]*)\((.*)\)$/s.exec(part);
|
||||
if (embed) {
|
||||
const relation = resource.relations.get(embed[1]);
|
||||
if (!relation) throw badRequest('unknown_relation', embed[1]);
|
||||
if (level + 1 > limits.depth) throw badRequest('too_deep', embed[1]);
|
||||
claim(embed[1]);
|
||||
out.embeds.push({ name: embed[1], relation, select: selection(relation.target, embed[2], limits, level + 1), order: [] });
|
||||
continue;
|
||||
}
|
||||
const name = column(resource, single(part)).name;
|
||||
claim(name);
|
||||
out.columns.push(name);
|
||||
}
|
||||
if (out.aggregates.length > 0 && out.embeds.length > 0) throw badRequest('aggregate_with_embed');
|
||||
if (out.embeds.some((e) => e.select.aggregates.length > 0)) throw badRequest('aggregate_in_embed');
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { badRequest } from '../errors.js';
|
||||
|
||||
/** Splits on top-level commas, keeping commas inside double quotes and parentheses. */
|
||||
export function splitTopLevel(input: string): string[] {
|
||||
const out: string[] = [];
|
||||
let depth = 0;
|
||||
let quoted = false;
|
||||
let start = 0;
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
const c = input[i];
|
||||
if (c === '\\' && quoted) { i++; continue; }
|
||||
if (c === '"') quoted = !quoted;
|
||||
else if (!quoted && c === '(') depth++;
|
||||
else if (!quoted && c === ')') depth--;
|
||||
else if (!quoted && depth === 0 && c === ',') { out.push(input.slice(start, i)); start = i + 1; }
|
||||
if (depth < 0) throw badRequest('bad_syntax', input);
|
||||
}
|
||||
if (quoted || depth !== 0) throw badRequest('bad_syntax', input);
|
||||
out.push(input.slice(start));
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A value item: double quotes delimit it and a backslash escapes the next character. */
|
||||
export function unquote(item: string): string {
|
||||
const t = item.trim();
|
||||
if (t.length >= 2 && t.startsWith('"') && t.endsWith('"')) return t.slice(1, -1).replace(/\\(.)/g, '$1');
|
||||
return t;
|
||||
}
|
||||
|
||||
/** `(a,b,"c,d")` into its items. */
|
||||
export function listItems(value: string): string[] {
|
||||
if (!value.startsWith('(') || !value.endsWith(')')) throw badRequest('bad_list', value);
|
||||
const inner = value.slice(1, -1);
|
||||
return inner.trim() === '' ? [] : splitTopLevel(inner).map(unquote);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { LogicalType } from '../catalog.js';
|
||||
import { badRequest } from '../errors.js';
|
||||
import type { Operator, Value } from '../plan.js';
|
||||
|
||||
const COMPARABLE: ReadonlySet<LogicalType> = new Set(['text', 'integer', 'number', 'boolean', 'uuid', 'date', 'timestamp', 'time']);
|
||||
const ORDERED: ReadonlySet<LogicalType> = new Set(['text', 'integer', 'number', 'date', 'timestamp', 'time']);
|
||||
|
||||
/** Whether an operator applies to a column of this type, decided before any SQL. */
|
||||
export function operatorFits(op: Operator, type: LogicalType, value: string): boolean {
|
||||
if (op === 'is') return value === 'null' || (type === 'boolean' && (value === 'true' || value === 'false'));
|
||||
if (op === 'like' || op === 'ilike') return type === 'text';
|
||||
if (op === 'gt' || op === 'gte' || op === 'lt' || op === 'lte') return ORDERED.has(type);
|
||||
return COMPARABLE.has(type);
|
||||
}
|
||||
|
||||
export function orderable(type: LogicalType): boolean {
|
||||
return ORDERED.has(type) || type === 'boolean' || type === 'uuid';
|
||||
}
|
||||
|
||||
const CHECKS: Partial<Record<LogicalType, RegExp>> = {
|
||||
integer: /^-?\d{1,19}$/,
|
||||
number: /^-?\d+(\.\d+)?([eE][-+]?\d+)?$/,
|
||||
uuid: /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/,
|
||||
date: /^\d{4}-\d{2}-\d{2}$/,
|
||||
timestamp: /^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2}(\.\d{1,9})?)?)?(Z|[+-]\d{2}(:?\d{2})?)?$/,
|
||||
time: /^\d{2}:\d{2}(:\d{2}(\.\d{1,9})?)?$/,
|
||||
};
|
||||
|
||||
/**
|
||||
* A request value as a typed value to bind. Checking it here keeps databases
|
||||
* that convert loosely ('abc' = 0 in MySQL) from answering a different question.
|
||||
*/
|
||||
export function typed(raw: string, type: LogicalType, column: string): Value {
|
||||
const check = CHECKS[type];
|
||||
if (check && !check.test(raw)) throw badRequest('invalid_value', column);
|
||||
if (type === 'boolean') {
|
||||
if (raw !== 'true' && raw !== 'false') throw badRequest('invalid_value', column);
|
||||
return raw === 'true';
|
||||
}
|
||||
if (type === 'integer' && Number.isSafeInteger(Number(raw))) return Number(raw);
|
||||
return raw;
|
||||
}
|
||||
|
||||
/** A like pattern with PostgREST's `*` wildcard, other wildcards escaped. */
|
||||
export function likePattern(raw: string): string {
|
||||
return raw.replace(/[\\%_]/g, (c) => `\\${c}`).replace(/\*/g, '%');
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export { createREST, type Rest } from './rest.js';
|
||||
export { nodeListener, type NodeListenerOptions } from './node.js';
|
||||
export { RestError, badRequest, notFound } from './errors.js';
|
||||
export { DEFAULT_LIMITS, DEFAULT_SENSITIVE } from './options.js';
|
||||
export type { Hooks, Limits, ProcedureOptions, RelationOptions, ResourceOptions, RestOptions, RestRequest, RestResponse, WriteOptions } from './options.js';
|
||||
export type { Capabilities, Dialect, Tx } from './dialect.js';
|
||||
export type { Catalog, CatalogColumn, CatalogForeignKey, CatalogFunction, CatalogTable, LogicalType } from './catalog.js';
|
||||
export type { Column, Junction, Relation, Resource } from './resources.js';
|
||||
export type { Aggregate, Embed, Filter, Operator, OrderTerm, Path, ReadPlan, Selection, Value } from './plan.js';
|
||||
export { SqlPrimitives } from './sql/primitives.js';
|
||||
export { SqlRenderer, type Statement } from './sql/renderer.js';
|
||||
export { RenderContext } from './sql/context.js';
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
import type { RestResponse } from './options.js';
|
||||
import type { Rest } from './rest.js';
|
||||
|
||||
export interface NodeListenerOptions<C> {
|
||||
/** Builds the context hooks receive, e.g. the authenticated user. */
|
||||
context?(req: IncomingMessage): C | Promise<C>;
|
||||
/** Path the API is mounted under, e.g. /api. */
|
||||
prefix?: string;
|
||||
/** Largest request body read, in bytes (default 1 MiB). */
|
||||
maxBodyBytes?: number;
|
||||
}
|
||||
|
||||
function readBody(req: IncomingMessage, max: number): Promise<unknown> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
let size = 0;
|
||||
req.on('data', (c: Buffer) => {
|
||||
size += c.length;
|
||||
if (size > max) { reject(Object.assign(new Error('body_too_large'), { status: 413 })); req.destroy(); }
|
||||
else chunks.push(c);
|
||||
});
|
||||
req.on('end', () => {
|
||||
if (size === 0) return resolve(undefined);
|
||||
try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))); } catch { reject(Object.assign(new Error('bad_body'), { status: 400 })); }
|
||||
});
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
function send(res: ServerResponse, r: RestResponse): void {
|
||||
const body = r.body === undefined ? '' : JSON.stringify(r.body);
|
||||
res.writeHead(r.status, { ...r.headers, ...(body ? { 'content-type': 'application/json; charset=utf-8' } : {}) });
|
||||
res.end(body);
|
||||
}
|
||||
|
||||
/** A node:http request listener serving the API. */
|
||||
export function nodeListener<C>(rest: Rest<C>, options: NodeListenerOptions<C> = {}) {
|
||||
const prefix = options.prefix?.replace(/\/$/, '') ?? '';
|
||||
return async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
||||
const url = new URL(req.url ?? '/', 'http://localhost');
|
||||
if (prefix && url.pathname !== prefix && !url.pathname.startsWith(`${prefix}/`)) return send(res, { status: 404, headers: {}, body: { error: 'not_found' } });
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await readBody(req, options.maxBodyBytes ?? 1 << 20);
|
||||
} catch (err) {
|
||||
const status = (err as { status?: number }).status ?? 400;
|
||||
return send(res, { status, headers: {}, body: { error: status === 413 ? 'body_too_large' : 'bad_body' } });
|
||||
}
|
||||
const headers = Object.fromEntries(Object.entries(req.headers).map(([k, v]) => [k, Array.isArray(v) ? v.join(', ') : v]));
|
||||
send(res, await rest.handle({
|
||||
method: req.method ?? 'GET',
|
||||
path: url.pathname.slice(prefix.length) || '/',
|
||||
query: url.searchParams,
|
||||
headers,
|
||||
body,
|
||||
context: (await options.context?.(req)) as C,
|
||||
}));
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { Dialect, Tx } from './dialect.js';
|
||||
import type { Value } from './plan.js';
|
||||
|
||||
export interface Limits {
|
||||
/** Rows in a page when the request names no limit. */
|
||||
page: number;
|
||||
/** Largest limit a request may ask for; more is refused, not clamped. */
|
||||
maxPage: number;
|
||||
/** Relations a path or an embed may walk through. */
|
||||
depth: number;
|
||||
/** Filters, including the terms of every or/and, in one request. */
|
||||
filters: number;
|
||||
/** Items in one in.(...) list. */
|
||||
inList: number;
|
||||
/** Rows in one write body. */
|
||||
bodyRows: number;
|
||||
/** Statement timeout, set by the dialect on every transaction. */
|
||||
timeoutMs: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_LIMITS: Limits = { page: 50, maxPage: 1000, depth: 2, filters: 50, inList: 500, bodyRows: 1000, timeoutMs: 5000 };
|
||||
|
||||
export type RelationOptions =
|
||||
| { one: string; via: string; references?: string }
|
||||
| { many: string; via: string; references?: string }
|
||||
| { many: string; through: string };
|
||||
|
||||
export interface WriteOptions {
|
||||
/** true: every visible column that is not generated. A list: only those. */
|
||||
create?: boolean | string[];
|
||||
update?: boolean | string[];
|
||||
delete?: boolean;
|
||||
/** PATCH and DELETE by filter on the collection, not only by id. */
|
||||
bulk?: boolean;
|
||||
}
|
||||
|
||||
export interface ResourceOptions {
|
||||
/** false removes a discovered table or view from the API. */
|
||||
expose?: boolean;
|
||||
/** Columns kept out of the API although their names look harmless. */
|
||||
hide?: string[];
|
||||
/** Columns shown although their names look sensitive. */
|
||||
show?: string[];
|
||||
/** Off by default: true allows create, update and delete of visible columns. */
|
||||
write?: boolean | WriteOptions;
|
||||
/** Columns that identify a row, for a view or a table without a primary key. */
|
||||
key?: string[];
|
||||
/** Override, add (any other name) or remove (false) a relation. */
|
||||
relations?: Record<string, RelationOptions | false>;
|
||||
}
|
||||
|
||||
export interface ProcedureOptions {
|
||||
/** Schema-qualified function name. */
|
||||
function: string;
|
||||
}
|
||||
|
||||
export interface RestRequest<C = unknown> {
|
||||
method: string;
|
||||
/** Path without the mount prefix, e.g. /assets/42. */
|
||||
path: string;
|
||||
query: URLSearchParams;
|
||||
headers: Record<string, string | undefined>;
|
||||
body?: unknown;
|
||||
/** Whatever the host passes in; it reaches every hook. */
|
||||
context: C;
|
||||
}
|
||||
|
||||
export interface RestResponse {
|
||||
status: number;
|
||||
headers: Record<string, string>;
|
||||
body: unknown;
|
||||
}
|
||||
|
||||
export interface Hooks<C = unknown> {
|
||||
/** Throw a RestError to refuse a request before any SQL runs. */
|
||||
authorize?(request: RestRequest<C>, target: { resource?: string; procedure?: string; action: 'read' | 'create' | 'update' | 'delete' | 'call' }): void | Promise<void>;
|
||||
/** First statement of every transaction: session settings, role, search path. */
|
||||
transaction?(tx: Tx, context: C): Promise<void>;
|
||||
/** Equality filters added to every query on a resource, for databases without row security. */
|
||||
scope?(resource: string, context: C): Record<string, Value> | undefined;
|
||||
/** Called with every error answered as a 500, for the host's logs. The client only sees `internal`. */
|
||||
onError?(error: unknown, request: RestRequest<C>): void;
|
||||
}
|
||||
|
||||
export interface RestOptions<C = unknown> {
|
||||
db: Dialect;
|
||||
/** Nothing is served until a schema or a table is named here. */
|
||||
expose: { schemas?: string[]; tables?: string[] };
|
||||
resources?: Record<string, ResourceOptions>;
|
||||
procedures?: Record<string, ProcedureOptions>;
|
||||
/** Replaces the default list of sensitive name patterns. */
|
||||
sensitive?: RegExp;
|
||||
hooks?: Hooks<C>;
|
||||
limits?: Partial<Limits>;
|
||||
}
|
||||
|
||||
/** Column names hidden unless shown on purpose. Matched against whole words of the name. */
|
||||
export const DEFAULT_SENSITIVE = /(^|_)(password|passwd|pwd|hash|hashed|salt|secret|token|api_?key|private_?key|otp|totp|mfa|recovery)(_|$)/i;
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { Relation, Resource } from './resources.js';
|
||||
|
||||
export const OPERATORS = ['eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'like', 'ilike', 'in', 'is'] as const;
|
||||
export type Operator = typeof OPERATORS[number];
|
||||
|
||||
export const AGGREGATES = ['count', 'sum', 'avg', 'min', 'max'] as const;
|
||||
export type Aggregate = typeof AGGREGATES[number];
|
||||
|
||||
/** A typed value ready to bind: the core checked it against the column's logical type. */
|
||||
export type Value = string | number | boolean | null;
|
||||
|
||||
/** Relations walked from the resource, then a column of the last one. */
|
||||
export interface Path { relations: Relation[]; column: string }
|
||||
|
||||
export type Filter =
|
||||
| { kind: 'cmp'; path: Path; op: Operator; negate: boolean; value: Value | Value[] }
|
||||
| { kind: 'present'; relations: Relation[]; negate: boolean }
|
||||
| { kind: 'or' | 'and'; negate: boolean; terms: Filter[] };
|
||||
|
||||
export type OrderKey = { kind: 'column'; path: Path } | { kind: 'count'; relations: Relation[] };
|
||||
export interface OrderTerm { key: OrderKey; desc: boolean; nulls?: 'first' | 'last' }
|
||||
|
||||
export interface Selection {
|
||||
columns: string[];
|
||||
aggregates: { fn: Aggregate; column: string | null }[];
|
||||
embeds: Embed[];
|
||||
}
|
||||
|
||||
export interface Embed {
|
||||
name: string;
|
||||
relation: Relation;
|
||||
select: Selection;
|
||||
order: OrderTerm[];
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface ReadPlan {
|
||||
resource: Resource;
|
||||
select: Selection;
|
||||
where: Filter[];
|
||||
order: OrderTerm[];
|
||||
limit: number;
|
||||
offset: number;
|
||||
count: 'none' | 'exact' | 'estimated';
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { Dialect, Tx } from './dialect.js';
|
||||
import { notFound } from './errors.js';
|
||||
import { parseRead } from './grammar/read.js';
|
||||
import type { Limits, RestRequest, RestResponse } from './options.js';
|
||||
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));
|
||||
if (plan.count === 'estimated' && db.capabilities.estimatedCount && db.estimate) {
|
||||
const n = await db.estimate(tx, stmt.sql, stmt.params);
|
||||
if (n !== null) return n;
|
||||
}
|
||||
const [row] = await tx.query(stmt.sql, stmt.params);
|
||||
return Number(row?.n ?? 0);
|
||||
}
|
||||
|
||||
export function keyFilter(resource: Resource, id: string): Filter {
|
||||
if (resource.key.length !== 1) throw notFound();
|
||||
const column = resource.all.get(resource.key[0])!;
|
||||
let value;
|
||||
try { value = typed(id, column.type, column.name); } catch { throw notFound(); }
|
||||
return { kind: 'cmp', path: { relations: [], column: column.name }, op: 'eq', negate: false, value };
|
||||
}
|
||||
|
||||
/** GET on a collection, or on one row when id is given. */
|
||||
export async function readRows(db: Dialect, tx: Tx, resource: Resource, request: RestRequest<unknown>, limits: Limits,
|
||||
scope: (r: Resource) => Filter[], id?: string): Promise<RestResponse> {
|
||||
const plan = parseRead(resource, request.query, limits);
|
||||
if (id !== undefined) {
|
||||
plan.where.unshift(keyFilter(resource, id));
|
||||
plan.limit = 1;
|
||||
plan.offset = 0;
|
||||
plan.count = 'none';
|
||||
}
|
||||
const stmt = db.renderer.read(plan, db.renderer.context(scope));
|
||||
const rows = (await tx.query(stmt.sql, stmt.params)).map(decode);
|
||||
if (id !== undefined) {
|
||||
if (rows.length === 0) throw notFound();
|
||||
return { status: 200, headers: {}, body: rows[0] };
|
||||
}
|
||||
const n = await total(db, tx, plan, scope);
|
||||
const range = rows.length ? `${plan.offset}-${plan.offset + rows.length - 1}` : '*';
|
||||
return { status: 200, headers: { 'content-range': `${range}/${n ?? '*'}` }, body: rows };
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { qualified, type Catalog, type CatalogForeignKey, type CatalogTable } from './catalog.js';
|
||||
import type { RelationOptions, ResourceOptions } from './options.js';
|
||||
import type { Relation, Resource } from './resources.js';
|
||||
|
||||
function stripId(columns: string[]): string | null {
|
||||
return columns.length === 1 && columns[0].endsWith('_id') && columns[0].length > 3 ? columns[0].slice(0, -3) : null;
|
||||
}
|
||||
|
||||
function taken(r: Resource, name: string): boolean {
|
||||
return r.all.has(name) || r.relations.has(name);
|
||||
}
|
||||
|
||||
/** Adds a discovered relation under the first free candidate name. */
|
||||
function addDiscovered(r: Resource, candidates: (string | null)[], rel: Omit<Relation, 'name'>): void {
|
||||
const name = candidates.find((c): c is string => !!c && !taken(r, c));
|
||||
if (!name) throw new Error(`restloom: no free name for a relation of ${r.name} to ${rel.target.name}; name it in resources.${r.name}.relations`);
|
||||
r.relations.set(name, { name, ...rel });
|
||||
}
|
||||
|
||||
/** A table whose primary key is exactly the columns of its two foreign keys links the two tables they point to. */
|
||||
function junctionKeys(t: CatalogTable): [CatalogForeignKey, CatalogForeignKey] | null {
|
||||
if (t.foreignKeys.length !== 2) return null;
|
||||
const fkCols = t.foreignKeys.flatMap((fk) => fk.columns);
|
||||
const pk = new Set(t.primaryKey);
|
||||
if (pk.size !== fkCols.length || !fkCols.every((c) => pk.has(c))) return null;
|
||||
return [t.foreignKeys[0], t.foreignKeys[1]];
|
||||
}
|
||||
|
||||
function explicit(name: string, source: Resource, o: RelationOptions, catalog: Catalog, resources: Map<string, Resource>): Relation {
|
||||
const target = resources.get('one' in o ? o.one : o.many);
|
||||
if (!target) throw new Error(`restloom: relation ${source.name}.${name} points to a resource that is not exposed`);
|
||||
const need = (r: Resource, c: string) => { if (!r.all.has(c)) throw new Error(`restloom: ${r.name} has no column ${c} for relation ${source.name}.${name}`); return c; };
|
||||
if ('one' in o) return { name, kind: 'one', target, from: [need(source, o.via)], to: [need(target, o.references ?? target.key[0] ?? 'id')] };
|
||||
if ('via' in o) return { name, kind: 'many', target, from: [need(source, o.references ?? source.key[0] ?? 'id')], to: [need(target, o.via)] };
|
||||
const ref = o.through.includes('.') ? o.through : `${source.schema}.${o.through}`;
|
||||
const j = catalog.tables.find((t) => qualified(t) === ref);
|
||||
const fkTo = (r: Resource) => j?.foreignKeys.find((fk) => fk.target.schema === r.schema && fk.target.table === r.table);
|
||||
const toSource = fkTo(source);
|
||||
const toTarget = j?.foreignKeys.find((fk) => fk !== toSource && fk.target.schema === target.schema && fk.target.table === target.table);
|
||||
if (!j || !toSource || !toTarget) throw new Error(`restloom: ${o.through} does not link ${source.name} and ${target.name} with two foreign keys`);
|
||||
return {
|
||||
name, kind: 'many', target, from: toSource.targetColumns, to: toTarget.targetColumns,
|
||||
through: { schema: j.schema, table: j.name, from: toSource.columns, to: toTarget.columns },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Relations between exposed resources: a foreign key gives a to-one relation on
|
||||
* its side and a to-many on the other, a junction table gives a many-to-many on
|
||||
* both ends. Relations to tables that are not exposed do not exist.
|
||||
*/
|
||||
export function discoverRelations(catalog: Catalog, resources: Map<string, Resource>, config: Record<string, ResourceOptions>): void {
|
||||
const byTable = new Map([...resources.values()].map((r) => [`${r.schema}.${r.table}`, r]));
|
||||
for (const t of catalog.tables) {
|
||||
const source = byTable.get(qualified(t));
|
||||
for (const fk of t.foreignKeys) {
|
||||
const target = byTable.get(qualified(fk.target));
|
||||
if (!source || !target) continue;
|
||||
const stem = stripId(fk.columns);
|
||||
addDiscovered(source, [stem, target.name, `${fk.columns.join('_')}_${target.name}`], { kind: 'one', target, from: fk.columns, to: fk.targetColumns });
|
||||
addDiscovered(target, [source.name, `${source.name}_by_${stem ?? fk.columns.join('_')}`], { kind: 'many', target: source, from: fk.targetColumns, to: fk.columns });
|
||||
}
|
||||
const pair = junctionKeys(t);
|
||||
const a = pair && byTable.get(qualified(pair[0].target));
|
||||
const b = pair && byTable.get(qualified(pair[1].target));
|
||||
if (!pair || !a || !b) continue;
|
||||
const via = (x: CatalogForeignKey, y: CatalogForeignKey) => ({ schema: t.schema, table: t.name, from: x.columns, to: y.columns });
|
||||
addDiscovered(a, [b.name, `${b.name}_via_${t.name}`], { kind: 'many', target: b, from: pair[0].targetColumns, to: pair[1].targetColumns, through: via(pair[0], pair[1]) });
|
||||
if (a !== b) addDiscovered(b, [a.name, `${a.name}_via_${t.name}`], { kind: 'many', target: a, from: pair[1].targetColumns, to: pair[0].targetColumns, through: via(pair[1], pair[0]) });
|
||||
}
|
||||
for (const [resName, o] of Object.entries(config)) {
|
||||
const source = resources.get(resName);
|
||||
if (!source) continue;
|
||||
for (const [name, ro] of Object.entries(o.relations ?? {})) {
|
||||
source.relations.delete(name);
|
||||
if (ro === false) continue;
|
||||
if (source.all.has(name)) throw new Error(`restloom: relation ${resName}.${name} has the name of a column`);
|
||||
source.relations.set(name, explicit(name, source, ro, catalog, resources));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { qualified, type Catalog, type CatalogColumn, type CatalogTable } from './catalog.js';
|
||||
import { DEFAULT_SENSITIVE, type ResourceOptions, type RestOptions } from './options.js';
|
||||
import { discoverRelations } from './relations.js';
|
||||
|
||||
export interface Column extends CatalogColumn {
|
||||
create: boolean;
|
||||
update: boolean;
|
||||
}
|
||||
|
||||
export interface Junction {
|
||||
schema: string;
|
||||
table: string;
|
||||
/** Junction columns matching the source key, in order. */
|
||||
from: string[];
|
||||
/** Junction columns matching the target key, in order. */
|
||||
to: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A relation from a resource to another. `one` and `many` match target.to
|
||||
* against source.from; `many` through a junction matches the junction instead.
|
||||
*/
|
||||
export interface Relation {
|
||||
name: string;
|
||||
kind: 'one' | 'many';
|
||||
target: Resource;
|
||||
from: string[];
|
||||
to: string[];
|
||||
through?: Junction;
|
||||
}
|
||||
|
||||
export interface Resource {
|
||||
name: string;
|
||||
schema: string;
|
||||
table: string;
|
||||
kind: 'table' | 'view';
|
||||
/** Visible columns: everything the API may read, filter, sort or write. */
|
||||
columns: Map<string, Column>;
|
||||
/** Every column, hidden ones included, for joins only. */
|
||||
all: Map<string, CatalogColumn>;
|
||||
key: string[];
|
||||
relations: Map<string, Relation>;
|
||||
write: { create: boolean; update: boolean; delete: boolean; bulk: boolean };
|
||||
}
|
||||
|
||||
function exposed(t: CatalogTable, expose: RestOptions['expose']): boolean {
|
||||
return (expose.schemas ?? []).includes(t.schema) || (expose.tables ?? []).includes(qualified(t));
|
||||
}
|
||||
|
||||
function names(tables: CatalogTable[]): Map<CatalogTable, string> {
|
||||
const count = new Map<string, number>();
|
||||
for (const t of tables) count.set(t.name, (count.get(t.name) ?? 0) + 1);
|
||||
return new Map(tables.map((t) => [t, count.get(t.name)! > 1 ? `${t.schema}_${t.name}` : t.name]));
|
||||
}
|
||||
|
||||
function columnsOf(t: CatalogTable, o: ResourceOptions, sensitive: RegExp): Map<string, Column> {
|
||||
const hide = new Set(o.hide ?? []);
|
||||
const show = new Set(o.show ?? []);
|
||||
for (const c of [...hide, ...show]) {
|
||||
if (!t.columns.some((col) => col.name === c)) throw new Error(`restloom: ${t.name} has no column ${c} to hide or show`);
|
||||
}
|
||||
const w = o.write === true ? { create: true, update: true } : o.write || {};
|
||||
const allowed = (setting: boolean | string[] | undefined, c: CatalogColumn) =>
|
||||
!c.generated && (setting === true || (Array.isArray(setting) && setting.includes(c.name)));
|
||||
const out = new Map<string, Column>();
|
||||
for (const c of t.columns) {
|
||||
if (hide.has(c.name) || (sensitive.test(c.name) && !show.has(c.name))) continue;
|
||||
const keyColumn = t.primaryKey.includes(c.name);
|
||||
out.set(c.name, { ...c, create: allowed(w.create, c), update: !keyColumn && allowed(w.update, c) });
|
||||
}
|
||||
for (const setting of [w.create, w.update]) {
|
||||
for (const c of Array.isArray(setting) ? setting : []) {
|
||||
if (!out.has(c)) throw new Error(`restloom: ${t.name}.${c} is not a visible column and cannot be writable`);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns a catalog into the API's resources. Only exposed tables and views
|
||||
* become resources; relations come from foreign keys between them, then the
|
||||
* options override, add or remove them.
|
||||
*/
|
||||
export function buildResources(catalog: Catalog, options: RestOptions<unknown>): Map<string, Resource> {
|
||||
const sensitive = options.sensitive ?? DEFAULT_SENSITIVE;
|
||||
const tables = catalog.tables.filter((t) => exposed(t, options.expose));
|
||||
const named = names(tables);
|
||||
const config = options.resources ?? {};
|
||||
for (const name of Object.keys(config)) {
|
||||
if (![...named.values()].includes(name)) throw new Error(`restloom: no exposed table or view is named ${name}`);
|
||||
}
|
||||
const resources = new Map<string, Resource>();
|
||||
for (const t of tables) {
|
||||
const name = named.get(t)!;
|
||||
const o = config[name] ?? {};
|
||||
if (o.expose === false) continue;
|
||||
const key = o.key ?? (t.primaryKey.length ? t.primaryKey : t.columns.some((c) => c.name === 'id') ? ['id'] : []);
|
||||
const w = o.write === true ? { delete: true, bulk: false } : o.write || {};
|
||||
const columns = columnsOf(t, o, sensitive);
|
||||
resources.set(name, {
|
||||
name, schema: t.schema, table: t.name, kind: t.kind, columns, key,
|
||||
all: new Map(t.columns.map((c) => [c.name, c])),
|
||||
relations: new Map(),
|
||||
write: {
|
||||
create: [...columns.values()].some((c) => c.create),
|
||||
update: [...columns.values()].some((c) => c.update),
|
||||
delete: w.delete === true,
|
||||
bulk: w.bulk === true,
|
||||
},
|
||||
});
|
||||
}
|
||||
discoverRelations(catalog, resources, config);
|
||||
return resources;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { Catalog } from './catalog.js';
|
||||
import { RestError, notFound } from './errors.js';
|
||||
import { DEFAULT_LIMITS, type Limits, type RestOptions, type RestRequest, type RestResponse } from './options.js';
|
||||
import { readRows } from './read.js';
|
||||
import { buildResources, type Resource } from './resources.js';
|
||||
import { scopeFilters } from './scope.js';
|
||||
|
||||
export interface Rest<C = unknown> {
|
||||
/** Answers one request. Never throws: every failure is a response. */
|
||||
handle(request: RestRequest<C>): Promise<RestResponse>;
|
||||
/** Rereads the catalog on the next request. */
|
||||
reload(): void;
|
||||
/** The resources the API serves, read from the catalog on first use. */
|
||||
resources(): Promise<ReadonlyMap<string, Resource>>;
|
||||
}
|
||||
|
||||
function errorBody(err: RestError): RestResponse {
|
||||
return { status: err.status, headers: {}, body: { error: err.code, ...(err.detail ? { message: err.detail } : {}) } };
|
||||
}
|
||||
|
||||
function route(path: string): { resource: string; id?: string } | null {
|
||||
const parts = path.split('/').filter((p) => p !== '');
|
||||
if (parts.length < 1 || parts.length > 2) return null;
|
||||
try {
|
||||
return { resource: decodeURIComponent(parts[0]), id: parts[1] === undefined ? undefined : decodeURIComponent(parts[1]) };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A REST API over the exposed tables and views of a database. Everything is
|
||||
* read from the catalog on first use; options only override.
|
||||
*/
|
||||
export function createREST<C = unknown>(options: RestOptions<C>): Rest<C> {
|
||||
const limits: Limits = { ...DEFAULT_LIMITS, ...options.limits };
|
||||
const db = options.db;
|
||||
let loading: Promise<Map<string, Resource>> | null = null;
|
||||
|
||||
const load = () => (loading ??= db.introspect({ schemas: options.expose.schemas ?? [], tables: options.expose.tables ?? [] })
|
||||
.then((catalog: Catalog) => buildResources(catalog, options as RestOptions<unknown>))
|
||||
.catch((err: unknown) => { loading = null; throw err; }));
|
||||
|
||||
async function dispatch(request: RestRequest<C>): Promise<RestResponse> {
|
||||
const target = route(request.path);
|
||||
const resource = target && (await load()).get(target.resource);
|
||||
if (!target || !resource) throw notFound();
|
||||
if (request.method !== 'GET') throw new RestError(405, 'method_not_allowed');
|
||||
await options.hooks?.authorize?.(request, { resource: resource.name, action: 'read' });
|
||||
const scope = scopeFilters(options, request.context);
|
||||
return db.transaction(async (tx) => {
|
||||
await options.hooks?.transaction?.(tx, request.context);
|
||||
return readRows(db, tx, resource, request as RestRequest<unknown>, limits, scope, target.id);
|
||||
}, { timeoutMs: limits.timeoutMs, readOnly: true });
|
||||
}
|
||||
|
||||
return {
|
||||
async handle(request) {
|
||||
try {
|
||||
return await dispatch(request);
|
||||
} catch (err) {
|
||||
if (err instanceof RestError) return errorBody(err);
|
||||
const mapped = db.mapError(err);
|
||||
if (mapped) return errorBody(mapped);
|
||||
options.hooks?.onError?.(err, request);
|
||||
return errorBody(new RestError(500, 'internal'));
|
||||
}
|
||||
},
|
||||
reload() { loading = null; },
|
||||
resources: load,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { Filter } from './plan.js';
|
||||
import type { Resource } from './resources.js';
|
||||
import type { RestOptions } from './options.js';
|
||||
|
||||
/** The host's scope filters for a resource, as equality filters on its own columns (hidden ones included). */
|
||||
export function scopeFilters<C>(options: RestOptions<C>, context: C): (resource: Resource) => Filter[] {
|
||||
const hook = options.hooks?.scope;
|
||||
if (!hook) return () => [];
|
||||
return (resource) => Object.entries(hook(resource.name, context) ?? {}).map(([column, value]): Filter => {
|
||||
if (!resource.all.has(column)) throw new Error(`restloom: scope for ${resource.name} names a column it does not have: ${column}`);
|
||||
return value === null
|
||||
? { kind: 'cmp', path: { relations: [], column }, op: 'is', negate: false, value: null }
|
||||
: { kind: 'cmp', path: { relations: [], column }, op: 'eq', negate: false, value };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { Filter } from '../plan.js';
|
||||
import type { Resource } from '../resources.js';
|
||||
import { Params } from './params.js';
|
||||
|
||||
/** State of one statement being written: its bound values, fresh aliases, and the host's scope per resource. */
|
||||
export class RenderContext {
|
||||
readonly params = new Params();
|
||||
private next = 0;
|
||||
constructor(readonly scope: (resource: Resource) => Filter[]) {}
|
||||
|
||||
alias(prefix = 't'): string {
|
||||
return `${prefix}${this.next++}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
const TOKEN = /\u0000(\d+)\u0000/g;
|
||||
|
||||
/**
|
||||
* Bound values of one statement. Fragments carry a token per value, so they can
|
||||
* be written in any order and repeated; finalize() writes the database's own
|
||||
* placeholders in text order. The token holds a NUL, which no identifier or
|
||||
* literal can contain.
|
||||
*/
|
||||
export class Params {
|
||||
readonly values: unknown[] = [];
|
||||
|
||||
add(value: unknown): string {
|
||||
this.values.push(value);
|
||||
return `\u0000${this.values.length}\u0000`;
|
||||
}
|
||||
|
||||
finalize(sql: string, placeholder: (index: number) => string): { sql: string; params: unknown[] } {
|
||||
const params: unknown[] = [];
|
||||
const text = sql.replace(TOKEN, (_, n: string) => {
|
||||
params.push(this.values[Number(n) - 1]);
|
||||
return placeholder(params.length);
|
||||
});
|
||||
return { sql: text, params };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { CatalogColumn } from '../catalog.js';
|
||||
import type { Aggregate } from '../plan.js';
|
||||
|
||||
/**
|
||||
* The smallest pieces of SQL, written for Postgres. A dialect overrides the
|
||||
* ones its database writes differently; the statements are built from these
|
||||
* pieces only, so an override reaches every statement that uses it.
|
||||
*/
|
||||
export class SqlPrimitives {
|
||||
quote(identifier: string): string {
|
||||
return `"${identifier.replace(/"/g, '""')}"`;
|
||||
}
|
||||
|
||||
literal(text: string): string {
|
||||
return `'${text.replace(/'/g, "''")}'`;
|
||||
}
|
||||
|
||||
placeholder(index: number): string {
|
||||
return `$${index}`;
|
||||
}
|
||||
|
||||
table(schema: string, table: string): string {
|
||||
return `${this.quote(schema)}.${this.quote(table)}`;
|
||||
}
|
||||
|
||||
column(alias: string, name: string): string {
|
||||
return `${alias}.${this.quote(name)}`;
|
||||
}
|
||||
|
||||
/** A column as it goes into a JSON object; a dialect fixes types its JSON functions misread. */
|
||||
jsonValue(expr: string, _column: CatalogColumn): string {
|
||||
return expr;
|
||||
}
|
||||
|
||||
/** A JSON value produced by a subquery, nested into an outer JSON object. */
|
||||
nested(expr: string): string {
|
||||
return expr;
|
||||
}
|
||||
|
||||
jsonObject(pairs: [string, string][]): string {
|
||||
return `json_build_object(${pairs.map(([k, v]) => `${this.literal(k)}, ${v}`).join(', ')})`;
|
||||
}
|
||||
|
||||
/** JSON array of rowExpr over a derived table, in the order of orderExpr, [] when empty. */
|
||||
jsonArray(rowExpr: string, orderExpr: string): string {
|
||||
return `COALESCE(json_agg(${rowExpr} ORDER BY ${orderExpr}), '[]'::json)`;
|
||||
}
|
||||
|
||||
like(expr: string, pattern: string, insensitive: boolean): string {
|
||||
return `${expr} ${insensitive ? 'ILIKE' : 'LIKE'} ${pattern} ESCAPE '\\'`;
|
||||
}
|
||||
|
||||
isBoolean(expr: string, value: boolean): string {
|
||||
return `${expr} IS ${value ? 'TRUE' : 'FALSE'}`;
|
||||
}
|
||||
|
||||
orderBy(expr: string, desc: boolean, nulls?: 'first' | 'last'): string {
|
||||
return `${expr} ${desc ? 'DESC' : 'ASC'}${nulls ? ` NULLS ${nulls.toUpperCase()}` : ''}`;
|
||||
}
|
||||
|
||||
/** limit and offset are integers checked by the grammar. */
|
||||
page(limit: number | undefined, offset: number | undefined): string {
|
||||
return `${limit !== undefined ? ` LIMIT ${limit}` : ''}${offset ? ` OFFSET ${offset}` : ''}`;
|
||||
}
|
||||
|
||||
rowNumber(orderSql: string): string {
|
||||
return `ROW_NUMBER() OVER (ORDER BY ${orderSql})`;
|
||||
}
|
||||
|
||||
aggregate(fn: Aggregate, expr: string | null): string {
|
||||
return fn === 'count' ? 'COUNT(*)' : `${fn.toUpperCase()}(${expr})`;
|
||||
}
|
||||
|
||||
/** A row-producing statement limited to its first row. */
|
||||
first(selectSql: string): string {
|
||||
return `${selectSql} LIMIT 1`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import type { Embed, Filter, OrderTerm, Path, ReadPlan, Selection, Value } from '../plan.js';
|
||||
import type { Relation, Resource } from '../resources.js';
|
||||
import { RenderContext } from './context.js';
|
||||
import { SqlPrimitives } from './primitives.js';
|
||||
|
||||
export interface Statement { sql: string; params: unknown[] }
|
||||
|
||||
const CMP: Record<string, string> = { eq: '=', neq: '<>', gt: '>', gte: '>=', lt: '<', lte: '<=' };
|
||||
|
||||
/**
|
||||
* Writes read statements from a plan. Relations are correlated subqueries, so
|
||||
* a statement is one SQL text whatever the depth, and every table it touches
|
||||
* carries the host's scope filters. Each method can be overridden by a dialect.
|
||||
*/
|
||||
export class SqlRenderer extends SqlPrimitives {
|
||||
context(scope: (r: Resource) => Filter[]): RenderContext {
|
||||
return new RenderContext(scope);
|
||||
}
|
||||
|
||||
/** FROM clause and correlation of a relation's target, seen from the parent alias. */
|
||||
relationSource(rel: Relation, parent: string, alias: string, ctx: RenderContext): { from: string; where: string[] } {
|
||||
const target = this.table(rel.target.schema, rel.target.table);
|
||||
const scoped = this.filters(rel.target, alias, ctx.scope(rel.target), ctx);
|
||||
if (!rel.through) {
|
||||
const on = rel.to.map((c, i) => `${this.column(alias, c)} = ${this.column(parent, rel.from[i])}`);
|
||||
return { from: `${target} ${alias}`, where: [...on, ...scoped] };
|
||||
}
|
||||
const j = ctx.alias('j');
|
||||
const join = rel.to.map((c, i) => `${this.column(j, rel.through!.to[i])} = ${this.column(alias, c)}`).join(' AND ');
|
||||
const on = rel.from.map((c, i) => `${this.column(j, rel.through!.from[i])} = ${this.column(parent, c)}`);
|
||||
return { from: `${target} ${alias} JOIN ${this.table(rel.through.schema, rel.through.table)} ${j} ON ${join}`, where: [...on, ...scoped] };
|
||||
}
|
||||
|
||||
/** EXISTS through a chain of relations, with `inner` written against the last alias. */
|
||||
exists(relations: Relation[], parent: string, ctx: RenderContext, inner: (alias: string) => string | null): string {
|
||||
const [rel, ...rest] = relations;
|
||||
const alias = ctx.alias();
|
||||
const src = this.relationSource(rel, parent, alias, ctx);
|
||||
const tail = rest.length ? this.exists(rest, alias, ctx, inner) : inner(alias);
|
||||
const where = [...src.where, ...(tail ? [tail] : [])];
|
||||
return `EXISTS (SELECT 1 FROM ${src.from}${where.length ? ` WHERE ${where.join(' AND ')}` : ''})`;
|
||||
}
|
||||
|
||||
comparison(expr: string, f: Extract<Filter, { kind: 'cmp' }>, ctx: RenderContext): string {
|
||||
const bind = (v: Value) => ctx.params.add(v);
|
||||
switch (f.op) {
|
||||
case 'in': return `${expr} IN (${(f.value as Value[]).map(bind).join(', ')})`;
|
||||
case 'is': return f.value === null ? `${expr} IS NULL` : this.isBoolean(expr, f.value as boolean);
|
||||
case 'like': return this.like(expr, bind(f.value as Value), false);
|
||||
case 'ilike': return this.like(expr, bind(f.value as Value), true);
|
||||
default: return `${expr} ${CMP[f.op]} ${bind(f.value as Value)}`;
|
||||
}
|
||||
}
|
||||
|
||||
filter(alias: string, f: Filter, ctx: RenderContext): string {
|
||||
if (f.kind === 'present') return `${f.negate ? 'NOT ' : ''}${this.exists(f.relations, alias, ctx, () => null)}`;
|
||||
if (f.kind !== 'cmp') {
|
||||
const body = `(${f.terms.map((t) => this.filter(alias, t, ctx)).join(f.kind === 'or' ? ' OR ' : ' AND ')})`;
|
||||
return f.negate ? `NOT ${body}` : body;
|
||||
}
|
||||
if (f.path.relations.length === 0) {
|
||||
const cmp = this.comparison(this.column(alias, f.path.column), f, ctx);
|
||||
return f.negate ? `NOT (${cmp})` : cmp;
|
||||
}
|
||||
const found = this.exists(f.path.relations, alias, ctx, (a) => this.comparison(this.column(a, f.path.column), f, ctx));
|
||||
return f.negate ? `NOT ${found}` : found;
|
||||
}
|
||||
|
||||
filters(_resource: Resource, alias: string, list: Filter[], ctx: RenderContext): string[] {
|
||||
return list.map((f) => this.filter(alias, f, ctx));
|
||||
}
|
||||
|
||||
/** A value reached through to-one relations, or a count of a to-many one, as a scalar subquery. */
|
||||
pathValue(relations: Relation[], parent: string, ctx: RenderContext, leaf: (alias: string) => string): string {
|
||||
const [rel, ...rest] = relations;
|
||||
const alias = ctx.alias();
|
||||
const src = this.relationSource(rel, parent, alias, ctx);
|
||||
const value = rest.length ? this.pathValue(rest, alias, ctx, leaf) : leaf(alias);
|
||||
return `(${this.first(`SELECT ${value} FROM ${src.from} WHERE ${src.where.join(' AND ')}`)})`;
|
||||
}
|
||||
|
||||
orderExpr(t: OrderTerm, alias: string, ctx: RenderContext): string {
|
||||
if (t.key.kind === 'count') {
|
||||
const rels = t.key.relations;
|
||||
const counted = (parent: string) => {
|
||||
const a = ctx.alias();
|
||||
const src = this.relationSource(rels[rels.length - 1], parent, a, ctx);
|
||||
return `(SELECT COUNT(*) FROM ${src.from} WHERE ${src.where.join(' AND ')})`;
|
||||
};
|
||||
return rels.length === 1 ? counted(alias) : this.pathValue(rels.slice(0, -1), alias, ctx, counted);
|
||||
}
|
||||
const path: Path = t.key.path;
|
||||
return path.relations.length === 0
|
||||
? this.column(alias, path.column)
|
||||
: this.pathValue(path.relations, alias, ctx, (a) => this.column(a, path.column));
|
||||
}
|
||||
|
||||
order(terms: OrderTerm[], alias: string, ctx: RenderContext): string {
|
||||
return terms.map((t) => this.orderBy(this.orderExpr(t, alias, ctx), t.desc, t.nulls)).join(', ');
|
||||
}
|
||||
|
||||
rowJson(resource: Resource, select: Selection, alias: string, ctx: RenderContext): string {
|
||||
const pairs: [string, string][] = select.columns.map((c) => [c, this.jsonValue(this.column(alias, c), resource.columns.get(c)!)]);
|
||||
for (const a of select.aggregates) pairs.push([a.fn, this.aggregate(a.fn, a.column && this.column(alias, a.column))]);
|
||||
for (const e of select.embeds) pairs.push([e.name, this.nested(this.embed(e, alias, ctx))]);
|
||||
return this.jsonObject(pairs);
|
||||
}
|
||||
|
||||
embed(e: Embed, parent: string, ctx: RenderContext): string {
|
||||
const alias = ctx.alias();
|
||||
const src = this.relationSource(e.relation, parent, alias, ctx);
|
||||
const target = e.relation.target;
|
||||
const row = this.rowJson(target, e.select, alias, ctx);
|
||||
const where = ` WHERE ${src.where.join(' AND ')}`;
|
||||
if (e.relation.kind === 'one') return `(${this.first(`SELECT ${row} FROM ${src.from}${where}`)})`;
|
||||
const terms = e.order.length ? e.order : target.key.map((k) => ({ key: { kind: 'column' as const, path: { relations: [], column: k } }, desc: false }));
|
||||
const ordered = this.order(terms, alias, ctx);
|
||||
const inner = `SELECT ${row} AS r, ${this.rowNumber(ordered || '1')} AS n FROM ${src.from}${where}${ordered ? ` ORDER BY ${ordered}` : ''}${this.page(e.limit, e.offset)}`;
|
||||
return `(SELECT ${this.jsonArray('x.r', 'x.n')} FROM (${inner}) x)`;
|
||||
}
|
||||
|
||||
where(plan: ReadPlan, alias: string, ctx: RenderContext): string {
|
||||
const parts = [...this.filters(plan.resource, alias, ctx.scope(plan.resource), ctx), ...this.filters(plan.resource, alias, plan.where, ctx)];
|
||||
return parts.length ? ` WHERE ${parts.join(' AND ')}` : '';
|
||||
}
|
||||
|
||||
groupBy(plan: ReadPlan, alias: string): string {
|
||||
return plan.select.aggregates.length && plan.select.columns.length
|
||||
? ` GROUP BY ${plan.select.columns.map((c) => this.column(alias, c)).join(', ')}` : '';
|
||||
}
|
||||
|
||||
read(plan: ReadPlan, ctx: RenderContext): Statement {
|
||||
const alias = ctx.alias();
|
||||
const row = this.rowJson(plan.resource, plan.select, alias, ctx);
|
||||
const where = this.where(plan, alias, ctx);
|
||||
const ordered = this.order(plan.order, alias, ctx);
|
||||
const sql = `SELECT ${row} AS r FROM ${this.table(plan.resource.schema, plan.resource.table)} ${alias}${where}${this.groupBy(plan, alias)}${ordered ? ` ORDER BY ${ordered}` : ''}${this.page(plan.limit, plan.offset)}`;
|
||||
return ctx.params.finalize(sql, (i) => this.placeholder(i));
|
||||
}
|
||||
|
||||
count(plan: ReadPlan, ctx: RenderContext): Statement {
|
||||
const alias = ctx.alias();
|
||||
const from = `${this.table(plan.resource.schema, plan.resource.table)} ${alias}${this.where(plan, alias, ctx)}`;
|
||||
const sql = plan.select.aggregates.length && plan.select.columns.length
|
||||
? `SELECT COUNT(*) AS n FROM (SELECT 1 AS one FROM ${from}${this.groupBy(plan, alias)}) g`
|
||||
: plan.select.aggregates.length ? 'SELECT 1 AS n' : `SELECT COUNT(*) AS n FROM ${from}`;
|
||||
return ctx.params.finalize(sql, (i) => this.placeholder(i));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { DEFAULT_LIMITS } from '../dist/index.js';
|
||||
import { buildResources } from '../dist/resources.js';
|
||||
|
||||
const col = (name, type = 'text', extra = {}) => ({ name, type, nativeType: type, nullable: true, generated: false, ...extra });
|
||||
|
||||
/** The demo schema as a catalog, without a database. */
|
||||
export function catalog() {
|
||||
return {
|
||||
functions: [],
|
||||
tables: [
|
||||
{ schema: 'demo', name: 'users', kind: 'table', primaryKey: ['id'],
|
||||
columns: [col('id', 'integer'), col('name'), col('password_hash'), col('limit', 'integer'), col('manager_id', 'integer')],
|
||||
foreignKeys: [{ columns: ['manager_id'], target: { schema: 'demo', table: 'users' }, targetColumns: ['id'] }] },
|
||||
{ schema: 'demo', name: 'assets', kind: 'table', primaryKey: ['id'],
|
||||
columns: [col('id', 'integer'), col('name'), col('status'), col('owner_id', 'integer'), col('cost', 'number'), col('active', 'boolean'), col('created_at', 'timestamp', { generated: true })],
|
||||
foreignKeys: [{ columns: ['owner_id'], target: { schema: 'demo', table: 'users' }, targetColumns: ['id'] }] },
|
||||
{ schema: 'demo', name: 'tags', kind: 'table', primaryKey: ['id'], columns: [col('id', 'integer'), col('name')], foreignKeys: [] },
|
||||
{ schema: 'demo', name: 'asset_tags', kind: 'table', primaryKey: ['asset_id', 'tag_id'],
|
||||
columns: [col('asset_id', 'integer'), col('tag_id', 'integer')],
|
||||
foreignKeys: [
|
||||
{ columns: ['asset_id'], target: { schema: 'demo', table: 'assets' }, targetColumns: ['id'] },
|
||||
{ columns: ['tag_id'], target: { schema: 'demo', table: 'tags' }, targetColumns: ['id'] },
|
||||
] },
|
||||
{ schema: 'other', name: 'secrets', kind: 'table', primaryKey: ['id'], columns: [col('id', 'integer')], foreignKeys: [] },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function resources(options = {}) {
|
||||
return buildResources(catalog(), { db: null, expose: { schemas: ['demo'] }, ...options });
|
||||
}
|
||||
|
||||
export const limits = DEFAULT_LIMITS;
|
||||
@@ -0,0 +1,54 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { parseRead } from '../dist/grammar/read.js';
|
||||
import { SqlRenderer } from '../dist/index.js';
|
||||
import { resources, limits } from './fixture.mjs';
|
||||
|
||||
const r = resources();
|
||||
const parse = (res, qs) => parseRead(r.get(res), new URLSearchParams(qs), limits);
|
||||
const code = (fn) => { try { fn(); } catch (e) { return e.code; } return 'none'; };
|
||||
|
||||
test('unknown, hidden and quoted names', () => {
|
||||
assert.equal(code(() => parse('users', 'select=password_hash')), 'unknown_column');
|
||||
assert.equal(code(() => parse('users', 'nope=eq.1')), 'unknown_column');
|
||||
assert.deepEqual(parse('users', 'select="limit"').select.columns, ['limit']);
|
||||
assert.equal(parse('users', '"limit"=eq.2').where[0].path.column, 'limit');
|
||||
assert.equal(code(() => parse('users', 'limit=x')), 'bad_limit');
|
||||
});
|
||||
|
||||
test('values are checked against the column type', () => {
|
||||
assert.equal(code(() => parse('assets', 'id=eq.abc')), 'invalid_value');
|
||||
assert.equal(code(() => parse('assets', 'active=eq.yes')), 'invalid_value');
|
||||
assert.equal(code(() => parse('assets', 'cost=like.1*')), 'bad_operator');
|
||||
assert.equal(parse('assets', 'id=eq.4').where[0].value, 4);
|
||||
});
|
||||
|
||||
test('relation paths, presence and depth', () => {
|
||||
const f = parse('assets', 'owner.manager.name=eq.Ada').where[0];
|
||||
assert.deepEqual(f.path.relations.map((x) => x.name), ['owner', 'manager']);
|
||||
assert.equal(code(() => parse('assets', 'owner.manager.manager.name=eq.Ada')), 'too_deep');
|
||||
assert.equal(parse('assets', 'tags=is.empty').where[0].kind, 'present');
|
||||
assert.equal(code(() => parse('assets', 'order=tags.name')), 'bad_order');
|
||||
assert.equal(parse('assets', 'order=tags.count.desc').order[0].key.kind, 'count');
|
||||
});
|
||||
|
||||
test('limits are refused, not clamped', () => {
|
||||
assert.equal(code(() => parse('assets', 'limit=1001')), 'limit_exceeded');
|
||||
const many = Array.from({ length: 51 }, (_, i) => `id=neq.${i}`).join('&');
|
||||
assert.equal(code(() => parse('assets', many)), 'too_many_filters');
|
||||
});
|
||||
|
||||
test('the key is appended to the order; a grouped read orders by its columns', () => {
|
||||
assert.deepEqual(parse('assets', 'order=name.desc').order.map((o) => o.key.path.column), ['name', 'id']);
|
||||
assert.equal(code(() => parse('assets', 'select=status,count()&order=name')), 'bad_order');
|
||||
});
|
||||
|
||||
test('placeholders follow the text order, for positional databases too', () => {
|
||||
class Positional extends SqlRenderer { placeholder() { return '?'; } }
|
||||
const renderer = new Positional();
|
||||
const plan = parse('assets', 'select=id,owner(name)&status=eq.open');
|
||||
const scope = (res) => res.name === 'users' ? [{ kind: 'cmp', path: { relations: [], column: 'name' }, op: 'eq', negate: false, value: 'scoped' }] : [];
|
||||
const { sql, params } = renderer.read(plan, renderer.context(scope));
|
||||
assert.equal(sql.split('?').length - 1, params.length);
|
||||
assert.deepEqual(params, ['scoped', 'open']);
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { resources } from './fixture.mjs';
|
||||
|
||||
test('only exposed schemas become resources', () => {
|
||||
const r = resources();
|
||||
assert.deepEqual([...r.keys()].sort(), ['asset_tags', 'assets', 'tags', 'users']);
|
||||
});
|
||||
|
||||
test('sensitive columns are hidden unless shown, and hide removes any column', () => {
|
||||
assert.equal(resources().get('users').columns.has('password_hash'), false);
|
||||
assert.equal(resources({ resources: { users: { show: ['password_hash'] } } }).get('users').columns.has('password_hash'), true);
|
||||
assert.equal(resources({ resources: { users: { hide: ['name'] } } }).get('users').columns.has('name'), false);
|
||||
});
|
||||
|
||||
test('foreign keys give to-one, to-many and many-to-many relations', () => {
|
||||
const r = resources();
|
||||
const rel = (res, name) => { const x = r.get(res).relations.get(name); return x && `${x.kind}:${x.target.name}${x.through ? ':through' : ''}`; };
|
||||
assert.equal(rel('assets', 'owner'), 'one:users');
|
||||
assert.equal(rel('users', 'assets'), 'many:assets');
|
||||
assert.equal(rel('users', 'manager'), 'one:users');
|
||||
assert.equal(rel('users', 'users'), 'many:users');
|
||||
assert.equal(rel('assets', 'tags'), 'many:tags:through');
|
||||
assert.equal(rel('tags', 'assets'), 'many:assets:through');
|
||||
});
|
||||
|
||||
test('relations can be renamed, removed and declared', () => {
|
||||
const r = resources({ resources: { users: { relations: { users: false, reports: { many: 'users', via: 'manager_id' } } } } });
|
||||
assert.equal(r.get('users').relations.has('users'), false);
|
||||
assert.equal(r.get('users').relations.get('reports').kind, 'many');
|
||||
assert.throws(() => resources({ resources: { users: { relations: { name: { one: 'users', via: 'manager_id' } } } } }), /name of a column/);
|
||||
});
|
||||
|
||||
test('writes are off by default and never reach generated or key columns', () => {
|
||||
assert.equal(resources().get('assets').write.create, false);
|
||||
const a = resources({ resources: { assets: { write: true } } }).get('assets');
|
||||
assert.equal(a.columns.get('name').create, true);
|
||||
assert.equal(a.columns.get('created_at').create, false);
|
||||
assert.equal(a.columns.get('id').update, false);
|
||||
assert.equal(a.write.delete, true);
|
||||
});
|
||||
|
||||
test('options naming unknown resources or columns fail at load', () => {
|
||||
assert.throws(() => resources({ resources: { nope: {} } }), /no exposed table/);
|
||||
assert.throws(() => resources({ resources: { users: { hide: ['nope'] } } }), /no column nope/);
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": { "outDir": "dist", "rootDir": "src" },
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2026 Marc Villain
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2026 Marc Villain
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2026 Marc Villain
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2026 Marc Villain
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2026 Marc Villain
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "@crudy/restloom-postgres",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "Postgres dialect for restloom",
|
||||
"keywords": ["restloom", "postgres", "postgresql", "rest", "api"],
|
||||
"author": "Marc Villain",
|
||||
"homepage": "https://restloom.crudy.fr",
|
||||
"repository": { "type": "git", "url": "git+https://github.com/crudylabs/restloom.git", "directory": "packages/postgres" },
|
||||
"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": {
|
||||
"pg": "^8.11.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/pg": "^8.11.0",
|
||||
"pg": "^8.16.0",
|
||||
"typescript": "^5.9.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { RestError } from '@crudy/restloom';
|
||||
|
||||
const CODES: Record<string, [number, string]> = {
|
||||
'42501': [403, 'forbidden'],
|
||||
'23505': [409, 'duplicate'],
|
||||
'23503': [409, 'reference_missing'],
|
||||
'23502': [400, 'value_required'],
|
||||
'23514': [400, 'value_rejected'],
|
||||
'22P02': [400, 'invalid_value'],
|
||||
'22007': [400, 'invalid_value'],
|
||||
'22008': [400, 'invalid_value'],
|
||||
'22003': [400, 'invalid_value'],
|
||||
'22001': [400, 'invalid_value'],
|
||||
'22023': [400, 'invalid_value'],
|
||||
'57014': [503, 'timeout'],
|
||||
};
|
||||
|
||||
/** A Postgres error the client caused, by SQLSTATE. The database's own message is never passed on. */
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { SqlRenderer, type Dialect, type Tx } from '@crudy/restloom';
|
||||
import type { Pool } from 'pg';
|
||||
import { mapError } from './errors.js';
|
||||
import { introspect } from './introspect.js';
|
||||
|
||||
export { mapError } from './errors.js';
|
||||
|
||||
/** Postgres writes every primitive the core's defaults assume; subclass it to change one. */
|
||||
export class PostgresRenderer extends SqlRenderer {}
|
||||
|
||||
export interface PostgresOptions {
|
||||
renderer?: SqlRenderer;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Postgres dialect over a `pg` Pool. Every request runs in one transaction
|
||||
* with a local statement timeout, read-only for reads, so a `transaction` hook
|
||||
* can set the role and session settings that row security reads.
|
||||
*/
|
||||
export function postgres(pool: Pool, options: PostgresOptions = {}): Dialect {
|
||||
return {
|
||||
name: 'postgres',
|
||||
capabilities: { returning: true, estimatedCount: true, rowSecurity: true },
|
||||
renderer: options.renderer ?? new PostgresRenderer(),
|
||||
introspect: (scope) => introspect(pool, scope),
|
||||
async transaction(fn, { timeoutMs, readOnly }) {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query(readOnly ? 'BEGIN READ ONLY' : 'BEGIN');
|
||||
await client.query(`SET LOCAL statement_timeout = ${Math.max(1, Math.floor(timeoutMs))}`);
|
||||
const tx: Tx = { query: async (sql, params) => (await client.query(sql, params)).rows };
|
||||
const out = await fn(tx);
|
||||
await client.query('COMMIT');
|
||||
return out;
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK').catch(() => undefined);
|
||||
throw err;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
},
|
||||
mapError,
|
||||
async estimate(tx, sql, params) {
|
||||
const [row] = await tx.query(`EXPLAIN (FORMAT JSON) ${sql}`, params);
|
||||
const plan = (row?.['QUERY PLAN'] as { Plan?: { 'Plan Rows'?: number } }[] | undefined)?.[0]?.Plan;
|
||||
return typeof plan?.['Plan Rows'] === 'number' ? plan['Plan Rows'] : null;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { Catalog, CatalogColumn, CatalogTable } from '@crudy/restloom';
|
||||
import type { Pool } from 'pg';
|
||||
import { logical } from './types.js';
|
||||
|
||||
const TABLES = `
|
||||
SELECT c.oid, n.nspname AS schema, c.relname AS name, c.relkind AS kind
|
||||
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
WHERE c.relkind IN ('r', 'p', 'v', 'm', 'f')
|
||||
AND (n.nspname = ANY($1::text[]) OR n.nspname || '.' || c.relname = ANY($2::text[]))`;
|
||||
|
||||
const COLUMNS = `
|
||||
SELECT a.attrelid AS oid, a.attname AS name, format_type(a.atttypid, a.atttypmod) AS native,
|
||||
b.typname, b.typtype, b.typcategory, a.attnotnull AS notnull,
|
||||
(a.attgenerated <> '' OR a.attidentity = 'a') AS generated
|
||||
FROM pg_attribute a
|
||||
JOIN pg_type t ON t.oid = a.atttypid
|
||||
JOIN pg_type b ON b.oid = CASE WHEN t.typtype = 'd' THEN t.typbasetype ELSE t.oid END
|
||||
WHERE a.attrelid = ANY($1::oid[]) AND a.attnum > 0 AND NOT a.attisdropped
|
||||
ORDER BY a.attrelid, a.attnum`;
|
||||
|
||||
const PRIMARY_KEYS = `
|
||||
SELECT i.indrelid AS oid, array_agg(a.attname::text ORDER BY k.ord) AS columns
|
||||
FROM pg_index i
|
||||
CROSS JOIN LATERAL unnest(i.indkey) WITH ORDINALITY AS k(attnum, ord)
|
||||
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = k.attnum
|
||||
WHERE i.indisprimary AND i.indrelid = ANY($1::oid[])
|
||||
GROUP BY i.indrelid`;
|
||||
|
||||
const FOREIGN_KEYS = `
|
||||
SELECT con.conrelid AS oid, tn.nspname AS target_schema, tc.relname AS target_table,
|
||||
(SELECT array_agg(a.attname::text ORDER BY k.ord) FROM unnest(con.conkey) WITH ORDINALITY k(n, ord)
|
||||
JOIN pg_attribute a ON a.attrelid = con.conrelid AND a.attnum = k.n) AS columns,
|
||||
(SELECT array_agg(a.attname::text ORDER BY k.ord) FROM unnest(con.confkey) WITH ORDINALITY k(n, ord)
|
||||
JOIN pg_attribute a ON a.attrelid = con.confrelid AND a.attnum = k.n) AS target_columns
|
||||
FROM pg_constraint con
|
||||
JOIN pg_class tc ON tc.oid = con.confrelid
|
||||
JOIN pg_namespace tn ON tn.oid = tc.relnamespace
|
||||
WHERE con.contype = 'f' AND con.conrelid = ANY($1::oid[])
|
||||
ORDER BY con.conname`;
|
||||
|
||||
const FUNCTIONS = `
|
||||
SELECT n.nspname AS schema, p.proname AS name, p.proretset AS returns_set, p.pronargs AS nargs,
|
||||
p.pronargdefaults AS ndefaults, coalesce(p.proargnames, '{}') AS argnames,
|
||||
(SELECT array_agg(format_type(t, NULL) ORDER BY ord) FROM unnest(p.proargtypes) WITH ORDINALITY u(t, ord)) AS natives,
|
||||
(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[])`;
|
||||
|
||||
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> {
|
||||
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]),
|
||||
]);
|
||||
const byOid = new Map<number, CatalogTable>();
|
||||
for (const t of tables) {
|
||||
byOid.set(t.oid as number, {
|
||||
schema: t.schema as string, name: t.name as string,
|
||||
kind: t.kind === 'v' || t.kind === 'm' ? 'view' : 'table',
|
||||
columns: [], primaryKey: [], foreignKeys: [],
|
||||
});
|
||||
}
|
||||
for (const c of columns.rows) {
|
||||
const col: CatalogColumn = {
|
||||
name: c.name as string, nativeType: c.native as string, nullable: !c.notnull, generated: c.generated as boolean,
|
||||
type: logical(c.typname as string, c.typtype as string, c.typcategory as string),
|
||||
};
|
||||
byOid.get(c.oid as number)?.columns.push(col);
|
||||
}
|
||||
for (const k of keys.rows) byOid.get(k.oid as number)!.primaryKey = k.columns as string[];
|
||||
for (const f of fks.rows) {
|
||||
byOid.get(f.oid as number)!.foreignKeys.push({
|
||||
columns: f.columns as string[], targetColumns: f.target_columns as string[],
|
||||
target: { schema: f.target_schema as string, table: f.target_table as string },
|
||||
});
|
||||
}
|
||||
const functions = fns.rows.map((f) => {
|
||||
const names = f.argnames as string[];
|
||||
const types = (f.types as string[] | null) ?? [];
|
||||
const natives = (f.natives as string[] | null) ?? [];
|
||||
const nargs = f.nargs as number;
|
||||
return {
|
||||
schema: f.schema as string, name: f.name as string, returnsSet: f.returns_set as boolean,
|
||||
args: types.map((t, i) => {
|
||||
const [typname, typtype, category] = t.split(':');
|
||||
return { name: names[i] ?? '', type: logical(typname, typtype, category), nativeType: natives[i], hasDefault: i >= nargs - (f.ndefaults as number) };
|
||||
}),
|
||||
};
|
||||
});
|
||||
return { tables: [...byOid.values()], functions };
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { LogicalType } from '@crudy/restloom';
|
||||
|
||||
const BY_NAME: Record<string, LogicalType> = {
|
||||
int2: 'integer', int4: 'integer', int8: 'integer', oid: 'integer',
|
||||
numeric: 'number', float4: 'number', float8: 'number', money: 'number',
|
||||
bool: 'boolean', uuid: 'uuid',
|
||||
date: 'date', timestamp: 'timestamp', timestamptz: 'timestamp', time: 'time', timetz: 'time',
|
||||
text: 'text', varchar: 'text', bpchar: 'text', name: 'text', citext: 'text', inet: 'text', cidr: 'text',
|
||||
json: 'json', jsonb: 'json', bytea: 'binary',
|
||||
};
|
||||
|
||||
/** A Postgres type (after resolving a domain to its base) as a logical type. Enums read as text. */
|
||||
export function logical(typname: string, typtype: string, category: string): LogicalType {
|
||||
if (BY_NAME[typname]) return BY_NAME[typname];
|
||||
if (typtype === 'e') return 'text';
|
||||
if (category === 'A') return 'array';
|
||||
return 'other';
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": { "outDir": "dist", "rootDir": "src" },
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2026 Marc Villain
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"declaration": true,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noImplicitReturns": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user