From 4b276f21e3149ad1fe9a0cbc637bbbdfecb553ed Mon Sep 17 00:00:00 2001 From: Marc Villain Date: Mon, 2 Nov 2026 01:11:38 +0100 Subject: [PATCH] feat: core grammar, discovery and renderer; Postgres dialect; conformance suite --- .github/workflows/ci.yml | 45 +++++ .gitignore | 4 + CHANGELOG.md | 11 ++ LICENSE | 201 ++++++++++++++++++++ README.md | 56 ++++++ conformance/examples.mjs | 33 ++++ conformance/fixtures/postgres.sql | 60 ++++++ conformance/postgres.test.mjs | 29 +++ docs/query-grammar.md | 237 +++++++++++++++++++++++ package-lock.json | 260 ++++++++++++++++++++++++++ package.json | 17 ++ packages/core/LICENSE | 201 ++++++++++++++++++++ packages/core/package.json | 24 +++ packages/core/src/catalog.ts | 49 +++++ packages/core/src/dialect.ts | 32 ++++ packages/core/src/errors.ts | 12 ++ packages/core/src/grammar/filter.ts | 83 ++++++++ packages/core/src/grammar/names.ts | 48 +++++ packages/core/src/grammar/order.ts | 51 +++++ packages/core/src/grammar/read.ts | 81 ++++++++ packages/core/src/grammar/resolve.ts | 28 +++ packages/core/src/grammar/select.ts | 62 ++++++ packages/core/src/grammar/split.ts | 35 ++++ packages/core/src/grammar/values.ts | 47 +++++ packages/core/src/index.ts | 12 ++ packages/core/src/node.ts | 60 ++++++ packages/core/src/options.ts | 98 ++++++++++ packages/core/src/plan.ts | 46 +++++ packages/core/src/read.ts | 52 ++++++ packages/core/src/relations.ts | 81 ++++++++ packages/core/src/resources.ts | 114 +++++++++++ packages/core/src/rest.ts | 72 +++++++ packages/core/src/scope.ts | 15 ++ packages/core/src/sql/context.ts | 14 ++ packages/core/src/sql/params.ts | 25 +++ packages/core/src/sql/primitives.ts | 78 ++++++++ packages/core/src/sql/renderer.ts | 149 +++++++++++++++ packages/core/test/fixture.mjs | 33 ++++ packages/core/test/grammar.test.mjs | 54 ++++++ packages/core/test/resources.test.mjs | 46 +++++ packages/core/tsconfig.json | 5 + packages/express/LICENSE | 201 ++++++++++++++++++++ packages/fastify/LICENSE | 201 ++++++++++++++++++++ packages/mssql/LICENSE | 201 ++++++++++++++++++++ packages/mysql/LICENSE | 201 ++++++++++++++++++++ packages/postgres/LICENSE | 201 ++++++++++++++++++++ packages/postgres/package.json | 32 ++++ packages/postgres/src/errors.ts | 23 +++ packages/postgres/src/index.ts | 49 +++++ packages/postgres/src/introspect.ts | 96 ++++++++++ packages/postgres/src/types.ts | 18 ++ packages/postgres/tsconfig.json | 5 + packages/sqlite/LICENSE | 201 ++++++++++++++++++++ tsconfig.base.json | 14 ++ 54 files changed, 4103 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 conformance/examples.mjs create mode 100644 conformance/fixtures/postgres.sql create mode 100644 conformance/postgres.test.mjs create mode 100644 docs/query-grammar.md create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 packages/core/LICENSE create mode 100644 packages/core/package.json create mode 100644 packages/core/src/catalog.ts create mode 100644 packages/core/src/dialect.ts create mode 100644 packages/core/src/errors.ts create mode 100644 packages/core/src/grammar/filter.ts create mode 100644 packages/core/src/grammar/names.ts create mode 100644 packages/core/src/grammar/order.ts create mode 100644 packages/core/src/grammar/read.ts create mode 100644 packages/core/src/grammar/resolve.ts create mode 100644 packages/core/src/grammar/select.ts create mode 100644 packages/core/src/grammar/split.ts create mode 100644 packages/core/src/grammar/values.ts create mode 100644 packages/core/src/index.ts create mode 100644 packages/core/src/node.ts create mode 100644 packages/core/src/options.ts create mode 100644 packages/core/src/plan.ts create mode 100644 packages/core/src/read.ts create mode 100644 packages/core/src/relations.ts create mode 100644 packages/core/src/resources.ts create mode 100644 packages/core/src/rest.ts create mode 100644 packages/core/src/scope.ts create mode 100644 packages/core/src/sql/context.ts create mode 100644 packages/core/src/sql/params.ts create mode 100644 packages/core/src/sql/primitives.ts create mode 100644 packages/core/src/sql/renderer.ts create mode 100644 packages/core/test/fixture.mjs create mode 100644 packages/core/test/grammar.test.mjs create mode 100644 packages/core/test/resources.test.mjs create mode 100644 packages/core/tsconfig.json create mode 100644 packages/express/LICENSE create mode 100644 packages/fastify/LICENSE create mode 100644 packages/mssql/LICENSE create mode 100644 packages/mysql/LICENSE create mode 100644 packages/postgres/LICENSE create mode 100644 packages/postgres/package.json create mode 100644 packages/postgres/src/errors.ts create mode 100644 packages/postgres/src/index.ts create mode 100644 packages/postgres/src/introspect.ts create mode 100644 packages/postgres/src/types.ts create mode 100644 packages/postgres/tsconfig.json create mode 100644 packages/sqlite/LICENSE create mode 100644 tsconfig.base.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7410ea4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..62ccde4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +*.tsbuildinfo +.DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..2568c3b --- /dev/null +++ b/CHANGELOG.md @@ -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. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..bd0fd2a --- /dev/null +++ b/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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..98b8dfc --- /dev/null +++ b/README.md @@ -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. diff --git a/conformance/examples.mjs b/conformance/examples.mjs new file mode 100644 index 0000000..4062ad3 --- /dev/null +++ b/conformance/examples.mjs @@ -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}`); +} diff --git a/conformance/fixtures/postgres.sql b/conformance/fixtures/postgres.sql new file mode 100644 index 0000000..be8c0c0 --- /dev/null +++ b/conformance/fixtures/postgres.sql @@ -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'); diff --git a/conformance/postgres.test.mjs b/conformance/postgres.test.mjs new file mode 100644 index 0000000..64e7c75 --- /dev/null +++ b/conformance/postgres.test.mjs @@ -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)); + } +} diff --git a/docs/query-grammar.md b/docs/query-grammar.md new file mode 100644 index 0000000..ef0acf0 --- /dev/null +++ b/docs/query-grammar.md @@ -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). diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..71ed5c5 --- /dev/null +++ b/package-lock.json @@ -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" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..01e99f1 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/packages/core/LICENSE b/packages/core/LICENSE new file mode 100644 index 0000000..bd0fd2a --- /dev/null +++ b/packages/core/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. diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 0000000..7393832 --- /dev/null +++ b/packages/core/package.json @@ -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" + } +} diff --git a/packages/core/src/catalog.ts b/packages/core/src/catalog.ts new file mode 100644 index 0000000..a79ba52 --- /dev/null +++ b/packages/core/src/catalog.ts @@ -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}`; +} diff --git a/packages/core/src/dialect.ts b/packages/core/src/dialect.ts new file mode 100644 index 0000000..15aea07 --- /dev/null +++ b/packages/core/src/dialect.ts @@ -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[]>; +} + +/** 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; + /** Runs fn in one transaction with a statement timeout; read-only when asked. */ + transaction(fn: (tx: Tx) => Promise, options: { timeoutMs: number; readOnly: boolean }): Promise; + /** 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; +} diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts new file mode 100644 index 0000000..1a1a5c0 --- /dev/null +++ b/packages/core/src/errors.ts @@ -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); diff --git a/packages/core/src/grammar/filter.ts b/packages/core/src/grammar/filter.ts new file mode 100644 index 0000000..1b45743 --- /dev/null +++ b/packages/core/src/grammar/filter.ts @@ -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; +} diff --git a/packages/core/src/grammar/names.ts b/packages/core/src/grammar/names.ts new file mode 100644 index 0000000..f73c99f --- /dev/null +++ b/packages/core/src/grammar/names.ts @@ -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]; +} diff --git a/packages/core/src/grammar/order.ts b/packages/core/src/grammar/order.ts new file mode 100644 index 0000000..cc2233c --- /dev/null +++ b/packages/core/src/grammar/order.ts @@ -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 = { 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; +} diff --git a/packages/core/src/grammar/read.ts b/packages/core/src/grammar/read.ts new file mode 100644 index 0000000..77b3844 --- /dev/null +++ b/packages/core/src/grammar/read.ts @@ -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', + }; +} diff --git a/packages/core/src/grammar/resolve.ts b/packages/core/src/grammar/resolve.ts new file mode 100644 index 0000000..280d192 --- /dev/null +++ b/packages/core/src/grammar/resolve.ts @@ -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; +} diff --git a/packages/core/src/grammar/select.ts b/packages/core/src/grammar/select.ts new file mode 100644 index 0000000..f4b6fdb --- /dev/null +++ b/packages/core/src/grammar/select.ts @@ -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(); + 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; +} diff --git a/packages/core/src/grammar/split.ts b/packages/core/src/grammar/split.ts new file mode 100644 index 0000000..0cf34db --- /dev/null +++ b/packages/core/src/grammar/split.ts @@ -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); +} diff --git a/packages/core/src/grammar/values.ts b/packages/core/src/grammar/values.ts new file mode 100644 index 0000000..d65f791 --- /dev/null +++ b/packages/core/src/grammar/values.ts @@ -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 = new Set(['text', 'integer', 'number', 'boolean', 'uuid', 'date', 'timestamp', 'time']); +const ORDERED: ReadonlySet = 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> = { + 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, '%'); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts new file mode 100644 index 0000000..598cb9f --- /dev/null +++ b/packages/core/src/index.ts @@ -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'; diff --git a/packages/core/src/node.ts b/packages/core/src/node.ts new file mode 100644 index 0000000..931a6ed --- /dev/null +++ b/packages/core/src/node.ts @@ -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 { + /** Builds the context hooks receive, e.g. the authenticated user. */ + context?(req: IncomingMessage): C | Promise; + /** 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 { + 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(rest: Rest, options: NodeListenerOptions = {}) { + const prefix = options.prefix?.replace(/\/$/, '') ?? ''; + return async (req: IncomingMessage, res: ServerResponse): Promise => { + 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, + })); + }; +} diff --git a/packages/core/src/options.ts b/packages/core/src/options.ts new file mode 100644 index 0000000..68005ed --- /dev/null +++ b/packages/core/src/options.ts @@ -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; +} + +export interface ProcedureOptions { + /** Schema-qualified function name. */ + function: string; +} + +export interface RestRequest { + method: string; + /** Path without the mount prefix, e.g. /assets/42. */ + path: string; + query: URLSearchParams; + headers: Record; + body?: unknown; + /** Whatever the host passes in; it reaches every hook. */ + context: C; +} + +export interface RestResponse { + status: number; + headers: Record; + body: unknown; +} + +export interface Hooks { + /** Throw a RestError to refuse a request before any SQL runs. */ + authorize?(request: RestRequest, target: { resource?: string; procedure?: string; action: 'read' | 'create' | 'update' | 'delete' | 'call' }): void | Promise; + /** First statement of every transaction: session settings, role, search path. */ + transaction?(tx: Tx, context: C): Promise; + /** Equality filters added to every query on a resource, for databases without row security. */ + scope?(resource: string, context: C): Record | undefined; + /** Called with every error answered as a 500, for the host's logs. The client only sees `internal`. */ + onError?(error: unknown, request: RestRequest): void; +} + +export interface RestOptions { + db: Dialect; + /** Nothing is served until a schema or a table is named here. */ + expose: { schemas?: string[]; tables?: string[] }; + resources?: Record; + procedures?: Record; + /** Replaces the default list of sensitive name patterns. */ + sensitive?: RegExp; + hooks?: Hooks; + limits?: Partial; +} + +/** 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; diff --git a/packages/core/src/plan.ts b/packages/core/src/plan.ts new file mode 100644 index 0000000..d038f2e --- /dev/null +++ b/packages/core/src/plan.ts @@ -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'; +} diff --git a/packages/core/src/read.ts b/packages/core/src/read.ts new file mode 100644 index 0000000..b2e9eb8 --- /dev/null +++ b/packages/core/src/read.ts @@ -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): 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 { + 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, limits: Limits, + scope: (r: Resource) => Filter[], id?: string): Promise { + 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 }; +} diff --git a/packages/core/src/relations.ts b/packages/core/src/relations.ts new file mode 100644 index 0000000..ad3e7fb --- /dev/null +++ b/packages/core/src/relations.ts @@ -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): 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): 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, config: Record): 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)); + } + } +} diff --git a/packages/core/src/resources.ts b/packages/core/src/resources.ts new file mode 100644 index 0000000..43aff63 --- /dev/null +++ b/packages/core/src/resources.ts @@ -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; + /** Every column, hidden ones included, for joins only. */ + all: Map; + key: string[]; + relations: Map; + 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 { + const count = new Map(); + 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 { + 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(); + 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): Map { + 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(); + 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; +} diff --git a/packages/core/src/rest.ts b/packages/core/src/rest.ts new file mode 100644 index 0000000..cd33d87 --- /dev/null +++ b/packages/core/src/rest.ts @@ -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 { + /** Answers one request. Never throws: every failure is a response. */ + handle(request: RestRequest): Promise; + /** Rereads the catalog on the next request. */ + reload(): void; + /** The resources the API serves, read from the catalog on first use. */ + resources(): Promise>; +} + +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(options: RestOptions): Rest { + const limits: Limits = { ...DEFAULT_LIMITS, ...options.limits }; + const db = options.db; + let loading: Promise> | null = null; + + const load = () => (loading ??= db.introspect({ schemas: options.expose.schemas ?? [], tables: options.expose.tables ?? [] }) + .then((catalog: Catalog) => buildResources(catalog, options as RestOptions)) + .catch((err: unknown) => { loading = null; throw err; })); + + async function dispatch(request: RestRequest): Promise { + 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, 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, + }; +} diff --git a/packages/core/src/scope.ts b/packages/core/src/scope.ts new file mode 100644 index 0000000..9955b06 --- /dev/null +++ b/packages/core/src/scope.ts @@ -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(options: RestOptions, 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 }; + }); +} diff --git a/packages/core/src/sql/context.ts b/packages/core/src/sql/context.ts new file mode 100644 index 0000000..6c7d2f5 --- /dev/null +++ b/packages/core/src/sql/context.ts @@ -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++}`; + } +} diff --git a/packages/core/src/sql/params.ts b/packages/core/src/sql/params.ts new file mode 100644 index 0000000..fc1358a --- /dev/null +++ b/packages/core/src/sql/params.ts @@ -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 }; + } +} diff --git a/packages/core/src/sql/primitives.ts b/packages/core/src/sql/primitives.ts new file mode 100644 index 0000000..9e19ca1 --- /dev/null +++ b/packages/core/src/sql/primitives.ts @@ -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`; + } +} diff --git a/packages/core/src/sql/renderer.ts b/packages/core/src/sql/renderer.ts new file mode 100644 index 0000000..ed94d83 --- /dev/null +++ b/packages/core/src/sql/renderer.ts @@ -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 = { 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, 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)); + } +} diff --git a/packages/core/test/fixture.mjs b/packages/core/test/fixture.mjs new file mode 100644 index 0000000..957ae12 --- /dev/null +++ b/packages/core/test/fixture.mjs @@ -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; diff --git a/packages/core/test/grammar.test.mjs b/packages/core/test/grammar.test.mjs new file mode 100644 index 0000000..c06fb86 --- /dev/null +++ b/packages/core/test/grammar.test.mjs @@ -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']); +}); diff --git a/packages/core/test/resources.test.mjs b/packages/core/test/resources.test.mjs new file mode 100644 index 0000000..5d21390 --- /dev/null +++ b/packages/core/test/resources.test.mjs @@ -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/); +}); diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json new file mode 100644 index 0000000..972facb --- /dev/null +++ b/packages/core/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "outDir": "dist", "rootDir": "src" }, + "include": ["src"] +} diff --git a/packages/express/LICENSE b/packages/express/LICENSE new file mode 100644 index 0000000..bd0fd2a --- /dev/null +++ b/packages/express/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. diff --git a/packages/fastify/LICENSE b/packages/fastify/LICENSE new file mode 100644 index 0000000..bd0fd2a --- /dev/null +++ b/packages/fastify/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. diff --git a/packages/mssql/LICENSE b/packages/mssql/LICENSE new file mode 100644 index 0000000..bd0fd2a --- /dev/null +++ b/packages/mssql/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. diff --git a/packages/mysql/LICENSE b/packages/mysql/LICENSE new file mode 100644 index 0000000..bd0fd2a --- /dev/null +++ b/packages/mysql/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. diff --git a/packages/postgres/LICENSE b/packages/postgres/LICENSE new file mode 100644 index 0000000..bd0fd2a --- /dev/null +++ b/packages/postgres/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. diff --git a/packages/postgres/package.json b/packages/postgres/package.json new file mode 100644 index 0000000..03554a5 --- /dev/null +++ b/packages/postgres/package.json @@ -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" + } +} diff --git a/packages/postgres/src/errors.ts b/packages/postgres/src/errors.ts new file mode 100644 index 0000000..b5d2b43 --- /dev/null +++ b/packages/postgres/src/errors.ts @@ -0,0 +1,23 @@ +import { RestError } from '@crudy/restloom'; + +const CODES: Record = { + '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; +} diff --git a/packages/postgres/src/index.ts b/packages/postgres/src/index.ts new file mode 100644 index 0000000..e48e0f2 --- /dev/null +++ b/packages/postgres/src/index.ts @@ -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; + }, + }; +} diff --git a/packages/postgres/src/introspect.ts b/packages/postgres/src/introspect.ts new file mode 100644 index 0000000..e5e5732 --- /dev/null +++ b/packages/postgres/src/introspect.ts @@ -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; + +/** 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 { + const { rows: tables } = await pool.query(TABLES, [scope.schemas, scope.tables]); + const oids = tables.map((t) => t.oid as number); + const [columns, keys, fks, fns] = await Promise.all([ + pool.query(COLUMNS, [oids]), pool.query(PRIMARY_KEYS, [oids]), + pool.query(FOREIGN_KEYS, [oids]), pool.query(FUNCTIONS, [scope.schemas]), + ]); + const byOid = new Map(); + 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 }; +} diff --git a/packages/postgres/src/types.ts b/packages/postgres/src/types.ts new file mode 100644 index 0000000..1069f71 --- /dev/null +++ b/packages/postgres/src/types.ts @@ -0,0 +1,18 @@ +import type { LogicalType } from '@crudy/restloom'; + +const BY_NAME: Record = { + 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'; +} diff --git a/packages/postgres/tsconfig.json b/packages/postgres/tsconfig.json new file mode 100644 index 0000000..972facb --- /dev/null +++ b/packages/postgres/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "outDir": "dist", "rootDir": "src" }, + "include": ["src"] +} diff --git a/packages/sqlite/LICENSE b/packages/sqlite/LICENSE new file mode 100644 index 0000000..bd0fd2a --- /dev/null +++ b/packages/sqlite/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. diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..ec7ac1e --- /dev/null +++ b/tsconfig.base.json @@ -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 + } +}