From cc9b07d19f0a148249c67f5ee8749b94108d9c66 Mon Sep 17 00:00:00 2001 From: Jim Ezesinachi Date: Tue, 12 Nov 2024 10:44:50 +0100 Subject: [PATCH] docs: updates to Intro page and linked pages (#65) * fixes Signed-off-by: Jim Ezesinachi * fixes Signed-off-by: Jim Ezesinachi * updates Signed-off-by: Jim Ezesinachi * updates Signed-off-by: Jim Ezesinachi * final updates Signed-off-by: Jim Ezesinachi * updates Signed-off-by: Jim Ezesinachi * final updates Signed-off-by: Jim Ezesinachi * fixes Signed-off-by: Jim Ezesinachi * fixes Signed-off-by: Jim Ezesinachi --------- Signed-off-by: Jim Ezesinachi --- README.md | 15 +- packages/backend/README.md | 16 +- packages/backend/src/QueryEngine.ts | 6 +- packages/backend/src/SynthqlError.ts | 2 +- packages/backend/src/tests/queryEngine.ts | 8 +- .../blog/2024-05-10-why-json-schema/index.md | 21 +- .../index.md | 4 +- .../100-getting-started/000-quick-start.md | 11 +- .../docs/docs/100-getting-started/express.md | 18 +- .../docs/docs/100-getting-started/index.md | 18 ++ .../docs/docs/100-getting-started/next.md | 16 +- .../docs/docs/100-getting-started/react.md | 4 +- .../200-query-language/000-introduction.md | 44 +-- .../docs/200-query-language/100-examples.md | 137 ++++---- .../docs/200-query-language/composition.md | 36 +-- .../docs/300-security/000-Introduction.md | 181 ++++++----- .../300-security/100-query-whitelisting.md | 159 ++++++++++ .../300-security/200-query-permissions.md | 73 +++++ .../300-security/300-query-middlewares.md | 57 ++++ packages/docs/docs/300-security/index.md | 8 +- .../docs/300-security/query-middleware.md | 43 --- .../docs/300-security/query-permissions.md | 60 ---- .../docs/300-security/registered-queries.md | 149 --------- packages/docs/docs/500-deferred-queries.md | 33 +- packages/docs/docs/800-custom-providers.md | 86 ++++- packages/docs/docs/900-architecture.md | 8 +- packages/docs/src/pages/index.tsx | 189 +++++++---- .../static/reference/assets/navigation.js | 2 +- .../docs/static/reference/assets/search.js | 2 +- packages/handler-express/README.md | 2 +- packages/handler-next/README.md | 4 +- packages/queries/README.md | 4 +- packages/queries/src/QueryBuilderError.ts | 2 +- .../validateNestedQueriesHaveAValidRefOp.ts | 2 +- packages/react/README.md | 10 +- packages/react/src/useSynthql.test.tsx | 297 ++++++++++-------- .../react/src/useSynthqlExamples.test.tsx | 4 +- 37 files changed, 987 insertions(+), 744 deletions(-) create mode 100644 packages/docs/docs/300-security/100-query-whitelisting.md create mode 100644 packages/docs/docs/300-security/200-query-permissions.md create mode 100644 packages/docs/docs/300-security/300-query-middlewares.md delete mode 100644 packages/docs/docs/300-security/query-middleware.md delete mode 100644 packages/docs/docs/300-security/query-permissions.md delete mode 100644 packages/docs/docs/300-security/registered-queries.md diff --git a/README.md b/README.md index 9c199c5c..e8895c25 100644 --- a/README.md +++ b/README.md @@ -8,14 +8,21 @@ ## Show me some code ```ts +import { QueryEngine } from '@synthql/backend'; +import { from } from './generated'; + +export const queryEngine = new QueryEngine({ + url: 'postgresql://user:password@localhost:5432/dbname', +}); + const query = from('films') .columns('id', 'title', 'year') - .where({ id: { in: [1, 2, 3] } }) - .many(); + .filter({ id: { in: [1, 2, 3] } }) + .all(); -const { data } = useSynthql(query); +const data = await queryEngine.executeAndWait(query); -// `data` will resolve to +// `data` will resolve to: [ { id: 1, diff --git a/packages/backend/README.md b/packages/backend/README.md index ce3a2326..7fad4bf8 100644 --- a/packages/backend/README.md +++ b/packages/backend/README.md @@ -3,19 +3,21 @@ The SynthQL backend. ```ts -import { QueryEngine } from "@synthql/backend" +import { QueryEngine } from '@synthql/backend'; -// Initialize the client -const queryEngine = new QueryEngine({...}) +// Initialize the query engine +const queryEngine = new QueryEngine({ + url: 'postgresql://user:password@localhost:5432/dbname', +}); // Write your query const query = from('users') - .columns('id','first_name') - .where({id: {in:[1,2,3]}}) - .many() + .columns('id', 'first_name') + .filter({ id: { in: [1, 2, 3] } }) + .all(); // Execute the query -queryEngine.execute(query); +const result = await queryEngine.executeAndWait(query); ``` ## Links diff --git a/packages/backend/src/QueryEngine.ts b/packages/backend/src/QueryEngine.ts index 8fb0ec29..140ac820 100644 --- a/packages/backend/src/QueryEngine.ts +++ b/packages/backend/src/QueryEngine.ts @@ -213,9 +213,9 @@ export class QueryEngine { query: TQuery, opts?: { /** - * When using middlewares (via the `QueryEngine` options), - * pass the data that should be used to transform - * the query, via this option + * When using `.permissions()` (in your query) and/or `middlewares` + * (via the `QueryEngine` options), use this option to pass the + * data that should be used to transform and/or check the query * * e.g.: * diff --git a/packages/backend/src/SynthqlError.ts b/packages/backend/src/SynthqlError.ts index 306dde71..945203e8 100644 --- a/packages/backend/src/SynthqlError.ts +++ b/packages/backend/src/SynthqlError.ts @@ -130,7 +130,7 @@ export class SynthqlError extends Error { const lines = [ 'A query with a cardinality of `one` returned no results!', - 'Hint: are you using .one() when you should be using .maybe()?', + 'Hint: are you using .firstOrThrow() when you should be using .first()?', ]; return new SynthqlError(new Error(), type, lines.join('\n'), 404); diff --git a/packages/backend/src/tests/queryEngine.ts b/packages/backend/src/tests/queryEngine.ts index 981cb7ba..de50f24c 100644 --- a/packages/backend/src/tests/queryEngine.ts +++ b/packages/backend/src/tests/queryEngine.ts @@ -1,9 +1,10 @@ -import dotenv from 'dotenv'; +import { config } from 'dotenv'; import { Pool } from 'pg'; import { QueryEngine } from '../QueryEngine'; import { Middleware } from '../execution/middleware'; import { DB } from './generated'; -dotenv.config(); + +config(); export const pool = new Pool({ connectionString: @@ -12,12 +13,13 @@ export const pool = new Pool({ }); export function createQueryEngine(data?: { + schema?: string; middlewares?: Array>; dangerouslyIgnorePermissions?: boolean; }) { return new QueryEngine({ pool, - schema: 'public', + schema: data?.schema ?? 'public', middlewares: data?.middlewares, dangerouslyIgnorePermissions: data?.dangerouslyIgnorePermissions ?? true, diff --git a/packages/docs/blog/2024-05-10-why-json-schema/index.md b/packages/docs/blog/2024-05-10-why-json-schema/index.md index dc729384..dc1c05a2 100644 --- a/packages/docs/blog/2024-05-10-why-json-schema/index.md +++ b/packages/docs/blog/2024-05-10-why-json-schema/index.md @@ -5,7 +5,7 @@ authors: [fhur] tags: [devlog] --- -# Why json-schema? +## Why json-schema? I wanted to drop a few words on why we're chosing `JSON schema` as an intermediate representation for our schemas. Putting it in writing will make it clearer, so here goes. @@ -22,7 +22,7 @@ So we know that the query builder needs static type information. What's new is t Let's look at a very basic example. Find an actor by ID. ```ts -from('actors').where({ id: 1 }).maybe(); +from('actors').filter({ id: 1 }).first(); ``` We expect this to compile to something like @@ -39,9 +39,9 @@ Notice that I didn't write `select *`. That's intentional, because we can only s ```ts from('actor') - .where({id}) + .filter({id}) .include({ films }) - .maybe() + .first() .groupingId('actor_id') # <======= WHY DO I HAVE TO DO THIS? ``` @@ -61,11 +61,14 @@ const from = query().from; const from = query(db).from; ``` -# So... why JSON schema? +## So... why JSON schema? Ok, now that we've talked about some of the goals we want to support: let's go back to the original question. Why is JSON schema a good choice? -1. There is great tooling support for JSON schema: We can find libraries that generate zod from JSON schema or generate TypeScript types from json schema. -1. Building a JSON schema programmatically is really easy. Converting from `pg-extract-schema` to JSON schema is trivial, and very easy to unit test. -1. JSON schema itself is available at runtime: As JSON schema is just a plain old javascript object, it's available at runtime, and so we can pass it to the query builer as input so it can use it to infer the groupingId and select all the fields. -1. Runtime type checking: In the future we will want to add something like zod to the `QueryEngine` so that it blocks malformed queries. Using JSON Schema we can get zod for free. +1. There is great tooling support for JSON schema: We can find libraries that generate zod from JSON schema or generate TypeScript types from json schema. + +1. Building a JSON schema programmatically is really easy. Converting from `pg-extract-schema` to JSON schema is trivial, and very easy to unit test. + +1. JSON schema itself is available at runtime: As JSON schema is just a plain old javascript object, it's available at runtime, and so we can pass it to the query builer as input so it can use it to infer the groupingId and select all the fields. + +1. Runtime type checking: In the future we will want to add something like zod to the `QueryEngine` so that it blocks malformed queries. Using JSON Schema we can get zod for free. diff --git a/packages/docs/blog/2024-09-15-rfc-runtime-validation/index.md b/packages/docs/blog/2024-09-15-rfc-runtime-validation/index.md index 31a4eb81..7607ed45 100644 --- a/packages/docs/blog/2024-09-15-rfc-runtime-validation/index.md +++ b/packages/docs/blog/2024-09-15-rfc-runtime-validation/index.md @@ -106,6 +106,8 @@ const validateQueryResult = createValidator({ }); const queryEngine = new QueryEngine(); -const queryResult = queryEngine.executeAndWait(query); + +const queryResult = await queryEngine.executeAndWait(query); + validateQueryResult(queryResult); ``` diff --git a/packages/docs/docs/100-getting-started/000-quick-start.md b/packages/docs/docs/100-getting-started/000-quick-start.md index 95c3e5a0..adea6269 100644 --- a/packages/docs/docs/100-getting-started/000-quick-start.md +++ b/packages/docs/docs/100-getting-started/000-quick-start.md @@ -28,7 +28,7 @@ npx @synthql/cli generate \ In the example above, this will generate a types file at `src/generated/db.ts`, a schema definitions file at `src/generated/schema.ts` and an index file that connects both to the query builder and exports them, at `src/generated/index.ts`. -This connection allows you to export a type-safe query builder, `from()`, which has all the table and column names with the corresponding TypeScript types, as sourced from your database. +This connection allows you to simply import a type-safe query builder, `from()`, which includes all the table and column names along with their corresponding TypeScript types, sourced directly from your database. ## Write your first query @@ -40,6 +40,7 @@ import { from } from 'src/generated'; const findUserByIds = (ids: number[]) => { return ( + // Select table from('users') // Select which columns you want // NOTE: if you want to select all columns, simply don't use @@ -61,14 +62,8 @@ The `QueryEngine` compiles SynthQL queries into plain SQL and sends them to the // src/queryEngine.ts import { QueryEngine } from '@synthql/backend'; -// Ensure DATABASE_URL is set in your .env file: -// DATABASE_URL=postgresql://user:password@localhost:5432/dbname -if (!process.env.DATABASE_URL) { - throw new Error('DATABASE_URL environment variable is required!'); -} - export const queryEngine = new QueryEngine({ - url: process.env.DATABASE_URL, + url: 'postgresql://user:password@localhost:5432/dbname', }); ``` diff --git a/packages/docs/docs/100-getting-started/express.md b/packages/docs/docs/100-getting-started/express.md index 40beb8bb..40c804ee 100644 --- a/packages/docs/docs/100-getting-started/express.md +++ b/packages/docs/docs/100-getting-started/express.md @@ -4,7 +4,12 @@ import InstallPackage from '../../src/components/HomepageFeatures/InstallPackage :::tip Before reading this, first check out: -[Quick start: Node.js](./quick-start) if you haven't yet +[Quick start: Node.js](./quick-start) if you haven't already +::: + +:::info +After reading this, you'll probably want to set up your frontend app to send queries to your new SynthQL server. To get started, check out: +[Getting started: React](./react) if you haven't already ::: ## Install the packages @@ -31,14 +36,8 @@ The `QueryEngine` compiles SynthQL queries into plain SQL and sends them to the // src/queryEngine.ts import { QueryEngine } from '@synthql/backend'; -// Ensure DATABASE_URL is set in your .env file: -// DATABASE_URL=postgresql://user:password@localhost:5432/dbname -if (!process.env.DATABASE_URL) { - throw new Error('DATABASE_URL environment variable is required!'); -} - export const queryEngine = new QueryEngine({ - url: process.env.DATABASE_URL, + url: 'postgresql://user:password@localhost:5432/dbname', }); ``` @@ -53,8 +52,9 @@ import { queryEngine } from './queryEngine'; const app = express(); const expressSynthqlRequestHandler = createExpressSynthqlHandler(queryEngine); +// Create route handler app.post('/synthql', async (req, res) => { - return await expressSynthqlRequestHandler(req, res); + return expressSynthqlRequestHandler(req, res); }); app.listen(3000); diff --git a/packages/docs/docs/100-getting-started/index.md b/packages/docs/docs/100-getting-started/index.md index afd3c125..f04fc396 100644 --- a/packages/docs/docs/100-getting-started/index.md +++ b/packages/docs/docs/100-getting-started/index.md @@ -1,5 +1,23 @@ # Getting started +## Welcome to SynthQL! 🚀 + +Hey there, fellow developer! Ready to supercharge your data queries? You've landed in the right place. Let's get you up and running with SynthQL in no time! + +Whether you're a "give me the code right now" kind of developer or someone building the next big full-stack application, we've got you covered. Here's what's in store: + +Just want to dive in? 🏊‍♂️ Head straight to our "[Quick start](./getting-started/quick-start)" guide with Node.js - it's like a speed dating session with SynthQL! + +Building a server? 🏗️ Check out our server-side guides: + +Express.js fans, we've got your back with seamless [integration guide](./getting-started/express)! + +Next.js enthusiasts, your Route Handlers are about to get a serious upgrade! Start [here](./getting-started/next)! + +Frontend focused? 🎨 Our React [integration guide](./getting-started/react) will help you bring that SynthQL magic to your client-side applications, whether you're using plain React or any React-flavored framework. + +Pick your path below and let's start building something awesome. + ## Quick start: Node.js Execute your first SynthQL query. [Read more](./getting-started/quick-start). diff --git a/packages/docs/docs/100-getting-started/next.md b/packages/docs/docs/100-getting-started/next.md index e94a8a97..1f1d650f 100644 --- a/packages/docs/docs/100-getting-started/next.md +++ b/packages/docs/docs/100-getting-started/next.md @@ -4,7 +4,12 @@ import InstallPackage from '../../src/components/HomepageFeatures/InstallPackage :::tip Before reading this, first check out: -[Quick start: Node.js](./quick-start) if you haven't yet +[Quick start: Node.js](./quick-start) if you haven't already +::: + +:::info +After reading this, you'll probably want to set up your frontend app to send queries to your new SynthQL server. To get started, check out: +[Getting started: React](./react) if you haven't already ::: ## Install the packages @@ -31,14 +36,8 @@ The `QueryEngine` compiles SynthQL queries into plain SQL and sends them to the // src/queryEngine.ts import { QueryEngine } from '@synthql/backend'; -// Ensure DATABASE_URL is set in your .env file: -// DATABASE_URL=postgresql://user:password@localhost:5432/dbname -if (!process.env.DATABASE_URL) { - throw new Error('DATABASE_URL environment variable is required!'); -} - export const queryEngine = new QueryEngine({ - url: process.env.DATABASE_URL, + url: 'postgresql://user:password@localhost:5432/dbname', }); ``` @@ -51,6 +50,7 @@ import { queryEngine } from '../../../queryEngine'; const nextSynthqlRequestHandler = createNextSynthqlHandler(queryEngine); +// Create route handler export async function POST(request: Request) { return nextSynthqlRequestHandler(request); } diff --git a/packages/docs/docs/100-getting-started/react.md b/packages/docs/docs/100-getting-started/react.md index e140c180..6e4dc831 100644 --- a/packages/docs/docs/100-getting-started/react.md +++ b/packages/docs/docs/100-getting-started/react.md @@ -3,7 +3,7 @@ import InstallPackage from '../../src/components/HomepageFeatures/InstallPackage # React :::info -This guide assumes that you have setup a server to receive and execute SynthQL queries. If you haven't yet, check out: +This guide assumes that you have setup a server to receive and execute SynthQL queries. If you haven't already, check out: [Quick start: Node.js](./quick-start), [Getting started: Express.js](./express) and [Getting started: Next.js Route Handlers](./next) ::: @@ -33,7 +33,7 @@ npx @synthql/cli generate \ In the example above, this will generate a types file at `src/generated/db.ts`, a schema definitions file at `src/generated/schema.ts` and an index file that connects both to the query builder and exports them, at `src/generated/index.ts`. -This connection allows you to export a type-safe query builder, `from()`, which has all the table and column names with the corresponding TypeScript types, as sourced from your database. +This connection allows you to simply import a type-safe query builder, `from()`, which includes all the table and column names along with their corresponding TypeScript types, sourced directly from your database. ## React usage diff --git a/packages/docs/docs/200-query-language/000-introduction.md b/packages/docs/docs/200-query-language/000-introduction.md index 2777992f..69d872a7 100644 --- a/packages/docs/docs/200-query-language/000-introduction.md +++ b/packages/docs/docs/200-query-language/000-introduction.md @@ -5,57 +5,59 @@ SynthQL comes with a simple, but powerful query language. Let's see a few exampl ## Find user by ID ```ts -import { from } from './db'; +import { from } from './generated'; const users = from('users').columns('id', 'name'); export function findUserById(id: string) { - return users.where({ id }).maybe(); + return users.filter({ id }).first(); } ``` -## Find users by IDs +## Find user by IDs -```tsx -import { from } from './db'; +```ts +import { from } from './generated'; const users = from('users').columns('id', 'name'); -export function findUserById(ids: string[]) { - return users.where({ id: { in: ids } }).maybe(); +export function findUserByIds(ids: string[]) { + return users.filter({ id: { in: ids } }).first(); } ``` -## Find users with pets (1 to n relation) +## Find user with pets (1 to n relation) -```tsx -import { from } from './db'; +```ts +import { from } from './generated'; const pets = from('pets').columns('id', 'name', 'owner_id'); -const users = from('users').columns('id', 'name'); +const users = from('users').columns('id', 'name', 'address'); -export function findUserByIds(ids: string[]) { - const pets = pets - .where({ +export function findUserAndPetsByIds(ids: string[]) { + const userPets = pets + .filter({ owner_id: col('users.id'), }) - .many(); + .all(); + return users .include({ - pets, + userPets, }) - .where({ id: { in: ids } }) - .maybe(); + .filter({ id: { in: ids } }) + .first(); } ``` This query will return the following shape: ```ts -Array<{ +type UserAndPets = { id: string; name: string; - pets: Array<{ id: string; name: string }>; -}>; + address: string; + userPets: Array<{ id: string; name: string; owner_id: string }>; +}; ``` diff --git a/packages/docs/docs/200-query-language/100-examples.md b/packages/docs/docs/200-query-language/100-examples.md index b47c3b6c..93ae4921 100644 --- a/packages/docs/docs/200-query-language/100-examples.md +++ b/packages/docs/docs/200-query-language/100-examples.md @@ -1,86 +1,87 @@ # Examples -## Find a single actor by id with all selectable columns auto-selected +## Find 1 actor, with all selectable columns auto-selected, and no filters specified -Finds 0 or 1 record(s) in the `actors` table where the `id` is in the list of ids and return all selectable columns +Finds 1 record in the `actor` table ```ts -const q = from('actor') - .where({ actor_id: { in: [1] } }) - .one(); +const q = from('actor').firstOrThrow(); ``` -## Find a single actor by id with columns to return specified` +## Find 0 or 1 actor(s), with all selectable columns auto-selected, and no filters specified -Finds 0 or 1 record(s) in the `actors` table where the `id` is in the list of ids, and returns all selectable columns passed +Finds 0 or 1 record(s) in the `actor` table ```ts -const q = from('actor') - .columns('actor_id', 'first_name', 'last_name') - .where({ actor_id: { in: [1] } }) - .maybe(); +const q = from('actor').first(); ``` -## Find a single actor with no filters specified +## Find 1 actor by ID, with all selectable columns auto-selected -Finds 0 or 1 record(s) in the `actors` table +Finds 1 record in the `actor` table where the `actor_id` is in the list of IDs passed, and returns all selectable columns ```ts -const q = from('actor').columns('actor_id', 'first_name', 'last_name').one(); +const q = from('actor') + .filter({ actor_id: { in: [1] } }) + .firstOrThrow(); ``` -## Find a single actor with offset value specified +## Find 0 or 1 actor(s) by ID, with all selectable columns auto-selected -Finds 0 or 1 record(s) in the `actors` starting from the offset value position +Finds 0 or 1 record(s) in the `actor` table where the `actor_id` is in the list of IDs passed, and returns all selectable columns ```ts const q = from('actor') - .columns('actor_id', 'first_name', 'last_name') - .offset(1) - .one(); + .filter({ actor_id: { in: [1] } }) + .first(); ``` -## Find a single actor with limit of results to return specified +## Find 0 through n actor(s) by IDs, with columns to return specified -Finds n record(s) in the `actors`, where `n` is the value passed to `limit()` +Finds 0 through n record(s) in the `actor` table where their `actor_id` is in the list of IDs passed, and returns all selected columns ```ts const q = from('actor') .columns('actor_id', 'first_name', 'last_name') - .limit(2) - .many(); + .filter({ actor_id: { in: ids } }) + .all(); ``` -## Find a single actor with number of results to take specified +## Find 0 through n actor(s) with `limit(n)` of results to return specified -Finds n record(s) in the `actors`, where `n` is the value passed to `take()` +Finds 0 through n record(s) in the `actor` table, where `n` is the value passed to `limit()` ```ts -const q = from('actor').columns('actor_id', 'first_name', 'last_name').take(2); +const q = from('actor').limit(2).all(); ``` -## Find all actors by ids columns to return specified` +## Find 0 through n actor(s) with number of results to `take(n)` (shorthand for `.limit(n).all()`) specified -Finds all the records in the `actors` table where their `id` is in the list of ids, and returns all selectable columns passed +Finds 0 through n record(s) in the `actor` table, where `n` is the value passed to `take()` ```ts -const q = from('actor') - .columns('actor_id', 'first_name', 'last_name') - .where({ actor_id: { in: ids } }) - .many(); +const q = from('actor').take(2); +``` + +## Find 1 actor with `offset(n)` (offset value) specified + +Finds 1 record in the `actor` table, starting from the `offset(n)` (offset value) position + +```ts +const q = from('actor').offset(1).firstOrThrow(); ``` -## Find a single actor by id with a single-level-deep `include()` +## Find 1 customer by ID with a single-level-deep `include()` -Finds 1 record in the `customers` table where the `id` is in the list of ids +Finds 1 record in the `customers` table where the `actor_id` is in the list of IDs passed ```ts const store = from('store') .columns('store_id', 'address_id', 'manager_staff_id', 'last_update') - .where({ + .filter({ store_id: col('customer.store_id'), }) - .one(); + .firstOrThrow(); const q = from('customer') .columns( @@ -91,30 +92,30 @@ const q = from('customer') 'email', 'last_update', ) - .where({ customer_id: { in: [1] } }) + .filter({ customer_id: { in: [1] } }) .include({ store }) - .one(); + .firstOrThrow(); ``` -## Find a single customer by id with a two-level-deep `include()` +## Find 1 customer by ID with a two-level-deep `include()` -Finds 1 record in the `customers` table where the `id` is in the list of ids +Finds 1 record in the `customers` table where the `actor_id` is in the list of IDs passed ```ts const address = from('address') .columns('address_id', 'city_id', 'address', 'district', 'last_update') - .where({ + .filter({ address_id: col('store.address_id'), }) - .one(); + .firstOrThrow(); const store = from('store') .columns('store_id', 'address_id', 'manager_staff_id', 'last_update') - .where({ + .filter({ store_id: col('customer.store_id'), }) .include({ address }) - .one(); + .firstOrThrow(); const q = from('customer') .columns( @@ -125,38 +126,38 @@ const q = from('customer') 'email', 'last_update', ) - .where({ customer_id: { in: [4] } }) + .filter({ customer_id: { in: [4] } }) .include({ store }) - .one(); + .firstOrThrow(); ``` -## Find a single customer by id with a three-level-deep `include()` +## Find 1 customer by ID with a three-level-deep `include()` -Finds 1 record in the `customers` table where the `id` is in the list of ids +Finds 1 record in the `customers` table where the `actor_id` is in the list of IDs passed ```ts const city = from('city') .columns('city_id', 'country_id', 'city', 'last_update') - .where({ + .filter({ city_id: col('address.city_id'), }) - .one(); + .firstOrThrow(); const address = from('address') .columns('address_id', 'city_id', 'address', 'district', 'last_update') - .where({ + .filter({ address_id: col('store.address_id'), }) .include({ city }) - .one(); + .firstOrThrow(); const store = from('store') .columns('store_id', 'address_id', 'manager_staff_id', 'last_update') - .where({ + .filter({ store_id: col('customer.store_id'), }) .include({ address }) - .one(); + .firstOrThrow(); const q = from('customer') .columns( @@ -167,46 +168,46 @@ const q = from('customer') 'email', 'last_update', ) - .where({ customer_id: { in: [4] } }) + .filter({ customer_id: { in: [4] } }) .include({ store }) - .one(); + .firstOrThrow(); ``` -## Find a single customer by id with a four-level-deep `include()` +## Find 1 customer by ID with a four-level-deep `include()` -Finds 1 record in the `customers` table where the `id` is in the list of ids +Finds 1 record in the `customers` table where the `actor_id` is in the list of IDs passed ```ts const country = from('country') .columns('country_id', 'country', 'last_update') - .where({ + .filter({ country_id: col('city.country_id'), }) - .one(); + .firstOrThrow(); const city = from('city') .columns('city_id', 'city', 'country_id', 'last_update') - .where({ + .filter({ city_id: col('address.city_id'), }) .include({ country }) - .one(); + .firstOrThrow(); const address = from('address') .columns('address_id', 'city_id', 'address', 'district', 'last_update') - .where({ + .filter({ address_id: col('store.address_id'), }) .include({ city }) - .one(); + .firstOrThrow(); const store = from('store') .columns('store_id', 'address_id', 'manager_staff_id', 'last_update') - .where({ + .filter({ store_id: col('customer.store_id'), }) .include({ address }) - .one(); + .firstOrThrow(); const q = from('customer') .columns( @@ -217,7 +218,7 @@ const q = from('customer') 'email', 'last_update', ) - .where({ customer_id: { in: [4] } }) + .filter({ customer_id: { in: [4] } }) .include({ store }) - .one(); + .firstOrThrow(); ``` diff --git a/packages/docs/docs/200-query-language/composition.md b/packages/docs/docs/200-query-language/composition.md index 1054faa7..52188732 100644 --- a/packages/docs/docs/200-query-language/composition.md +++ b/packages/docs/docs/200-query-language/composition.md @@ -1,44 +1,40 @@ # Query composition & reuse -In my opinion one of the bigger issues with SQL is the fact that you cannot compose larger queries from simpler queries. +Although SQL supports query composition through JOINs, UNIONs, and subqueries, the readability and maintainability of large composite queries can be a challenge. -Effectively this means that it is impossible to share SQL fragments between queries, so if you have a Users query and a Pets query, to make a Pets with Owners query you have to make a completely different query. +SynthQL is designed for this kind of composition and allows you achieve this in several ways. Let's look at a few examples: -SynthQL is designed for composition and lets you achieve this in several ways. Let's see a few examples: +## Defining views -## Defining fragments - -The first step towards reusable queries is to be able to give a name to a table + columns. I call these `fragments` and they can be defined as follows +The first step towards reusable queries is to be able to give a name to a table and its columns. These are called `views`, and they can be defined as follows: ```ts // A view over the pets table -const pet = from('pets') - .column('id','name') +const pet = from('pets').columns('id', 'name'); // A view over the person table -const person = from('person') - .column('id','name','age') +const person = from('person').columns('id', 'name', 'age'); // A detailed view into the person table, along with their pets const personDetailed = from('person') - .column('id','name','age','created_at','updated_at', ...) + .columns('id', 'name', 'age', 'created_at', 'updated_at') .include({ - pet: pet.where({ owner_id: col('person.id') }).many() - }) + pet: pet.filter({ owner_id: col('person.id') }).all(), + }); ``` -Once you have views, you can easily turn these into queries as follows: +Once you have views, you can easily convert them into queries as follows: ```ts -function findPetById(id:number) { - return pet.where({id}).maybe() +function findPetById(id: number) { + return pet.filter({ id }).first(); } -funciton findPersonLight(id:number) { - return person.where({id}).maybe() +function findPersonLight(id: number) { + return person.filter({ id }).first(); } -function findPersonDetails(id:number) { - return personDetailed.where({id}).maybe() +function findPersonDetails(id: number) { + return personDetailed.filter({ id }).first(); } ``` diff --git a/packages/docs/docs/300-security/000-Introduction.md b/packages/docs/docs/300-security/000-Introduction.md index f35acc01..ab6cc6f8 100644 --- a/packages/docs/docs/300-security/000-Introduction.md +++ b/packages/docs/docs/300-security/000-Introduction.md @@ -1,125 +1,158 @@ ---- ---- - # Introduction -Letting clients make arbitrary queries, even if read-only comes with a set of security challenges. SynthQL comes with built in mechanisms to implement robust authorization logic so you can limit what queries clients can make. +Allowing clients to make arbitrary queries, even if read-only, comes with a set of security challenges. SynthQL provides built-in mechanisms to implement robust authorization logic, allowing you to limit what queries clients can execute. -Let's take a look at the different ways SynthQL ensures only the right queries will be sent to your database. +Let’s take a look at the different ways SynthQL ensures that only the right queries are sent to your database. ## Whitelisting queries -By default, the `QueryEngine` will not execute any query. It will only execute known queries. To register a query simply call the `registerQueries` method as follows. +By default, the `QueryEngine` will not execute any queries. It will only execute whitelisted queries. To add a query to the whitelist, simply call the `registerQueries()` method as follows: ```ts -import { from } from './db'; +import { from } from './generated'; +import { QueryEngine } from '@synthql/backend'; -const users = from('users').columns('id', 'name', 'email'); +const users = from('users').columns('id', 'name', 'email').all(); -const queryEngine = new QueryEngine(opts); +// Initialize query engine +const queryEngine = new QueryEngine({ + url: 'postgresql://user:password@localhost:5432/dbname', +}); -queryEngine.registerQueries(users); +// Add query(ies) to the whitelist +queryEngine.registerQueries([users]); ``` -What this means is that the `QueryEngine` will only allow queries on the `users` table and will allow any subset of the `id`, `name` and `email` columns to be selected. +What this means is that the `QueryEngine` will only allow queries on the `users` table and will permit any subset of the `id`, `name`, and `email` columns to be selected. -This behaviour can be disabled with the `allowUnknownQueries` option. +This feature can be disabled with the `dangerouslyAllowUnregisteredQueries` option. ```ts -const queryEngine = new QueryEngine({..., allowUnknownQueries:true}); +const queryEngine = new QueryEngine({ + url: 'postgresql://user:password@localhost:5432/dbname', + dangerouslyAllowUnregisteredQueries: true, +}); ``` -You can read more about registered queries [here](/docs/security/registered-queries). +You can read more about registered queries [here](/docs/security/query-whitelisting). ## Restricting access to tables and columns -You can use the `.requires` method to define what permissions are required to run the query. +You can use the `.permissions()` method to define the permissions required to run the query. ```ts const users = from('users') .columns('id', 'name', 'email') - .requires('users:read'); + .permissions('users:read'); const pets = from('pets') .columns('id', 'owner_id') - .requires('pets:read') + .permissions('pets:read') .include({ - owner: users.where({ owner_id: col('users.id') }).maybe(), - }); - -const userFull = from('users') - .columns('id', 'name', 'email', 'hashed_password') - .requires('users:read', 'users:admin'); + owner: users.filter({ owner_id: col('users.id') }).first(), + }) + .all(); ``` -When executing queries, you can pass a list of the user's current permissions: +When executing queries, you can pass a list of the user's current permissions as part of the query execution `context` object: ```ts -const user = { permissions: ['users:read', 'pets:read'] }; -queryEngine.execute(query, { user }); +// You want to generate this from some source, e.g. parsing the cookie sent with a HTTP request +const context = { + id: 1, + email: 'user@example.com', + isActive: true, + roles: ['user'], + permissions: ['users:read', 'pets:read'], +}; + +// Execute the query +const result = await queryEngine.executeAndWait(pets, { context }); ``` -The query engine will traverse the query recursively and reject the query unless it meets all the ACL requirements. +The `QueryEngine` will traverse the query recursively and reject it unless it meets all the ACL requirements. However, if you don't want these permissions (ACL requirements) to be checked, you can set the `dangerouslyIgnorePermissions` option when initializing the `QueryEngine`. + +```ts +const queryEngine = new QueryEngine({ + url: 'postgresql://user:password@localhost:5432/dbname', + dangerouslyIgnorePermissions: true, +}); +``` ## Restricting access to rows -Let's imagine an `orders` table that stores all orders made by `users`. A user should only ever be allowed to read it's own orders. This can be achieved with SynthQL, as follows: +Let’s imagine an `payment` table that stores all orders made by customers (users) in the `customer` table. A user should only be allowed to read their own orders. This can be achieved with SynthQL, as follows: -First, we define the schema. +First, we define the schema: -```tsx +```ts // queries.ts -import { from } from './db'; - -const orders = from('orders').columns( - 'id', - 'total_amount', - 'product_ids', - 'user_id', -); +const payments = from('payments') + .columns('id', 'total_amount', 'product_ids', 'customer_id') + .permissions('payments:read') + .all(); ``` -Now, let's imagine a client makes the following query. Note that this query will select all orders. +Now, let's examine the following query. Note that this query will select all orders. -```tsx -import { useSynthql } from '@synthql/react'; -import { orders } from './queries'; +```ts +import { payments } from './queries'; + +// You want to generate this from some source, e.g. parsing the cookie sent with a HTTP request +const context = { + id: 1, + email: 'user@example.com', + isActive: true, + roles: ['user'], + permissions: ['payments:read'], +}; + +// Execute the query +const result = await queryEngine.executeAndWait(payments, { context }); +``` -const query = orders.where(isNotNull('id')).many(); +To prevent these kinds of mistakes or abuses, you can add middlewares to the `QueryEngine`. A middleware is essentially a function that takes the query context and the current query, uses the context to transform the query, and then returns the newly transformed query. -useSynthql(query); -``` +In this example, we're creating a middleware that will act on every query to the `payment` table and add a filter to the `customer_id` column. -To prevent these kinds of mistakes or abuses, you can add middlewares to the `QueryEngine`. A middleware is essentially a function that takes the query context and the current query and return a new query context and a new query. - -In this example, we're creating a middleware that will act on every query to the `orders` table and will for a filter on the `user_id` column. - -```tsx -import { DB } from './db'; -import { QueryEngine, mapQuery } from '@synthql/backend'; -import { orders } from './queries'; - -const restrictOrdersByUser = middleware() - .from('orders') - .mapQuery((query, context) => { - const userId = context.user.id; - return { - context, - query: { - ...query, - // transforms the `where` to ensure that only orders can be read from the - // current user. - where: { - ...query.where, - user_id: userId, - }, - }, - }; - }); +```ts +// index.ts +import { DB } from './generated'; +import { QueryEngine, middleware } from '@synthql/backend'; +import { Query } from '@synthql/queries'; +import { payments } from './queries'; + +// Create types & interface for context +type UserRole = 'user' | 'admin' | 'super'; + +type UserPermission = 'user:read' | 'admin:read' | 'super:read'; + +interface Session { + id: number; + email: string; + isActive: boolean; + roles: UserRole[]; + permissions: UserPermission[]; +} + +// Create a middleware +const restrictPaymentsByCustomer = middleware, Session>({ + predicate: ({ query, context }) => + query?.from === 'payments' && + context?.roles?.includes('user') && + context?.isActive, + transformQuery: ({ query, context }) => ({ + ...query, + where: { + ...query.where, + customer_id: context.id, + }, + }), +}); +// Initialize query engine and register the middleware const queryEngine = new QueryEngine({ - middlewares: [restrictOrdersByUser], + url: 'postgresql://user:password@localhost:5432/dbname', + middlewares: [restrictPaymentsByCustomer], }); - -queryEngine.registerQueries(orders); ``` diff --git a/packages/docs/docs/300-security/100-query-whitelisting.md b/packages/docs/docs/300-security/100-query-whitelisting.md new file mode 100644 index 00000000..52f62709 --- /dev/null +++ b/packages/docs/docs/300-security/100-query-whitelisting.md @@ -0,0 +1,159 @@ +# Query whitelisting + +One of the core security goals of SynthQL is to be secure out of the box. This means that, by default, the `QueryEngine` will not execute unknown queries. Queries must be explicitly registered (i.e., `whitelisted`) with the `QueryEngine` in order to be executed. + +```ts +const findAllActiveUsers = () => + from('users').columns('id', 'name', 'email').filter({ active: true }).all(); + +const queryEngine = new QueryEngine({ + url: 'postgresql://user:password@localhost:5432/dbname', +}); + +// Register the query (add it to the whitelist) +queryEngine.registerQueries([findAllActiveUsers()]); + +// The `QueryEngine` will now only execute the registered queries +const result = await queryEngine.executeAndWait(findAllActiveUsers()); +``` + +## Why registered queries? + +Registered queries are a security feature that ensures only known queries are executed. This prevents potentially malicious actors from executing arbitrary queries on your database. + +When you build a traditional REST API, you implicitly "register queries" by defining the available endpoints. Since SynthQL is more dynamic, we need an explicit mechanism to indicate that a query was authored in a safe context. + +## How to register queries + +Registering queries is simple. All you need to do is pass the query to the `registerQueries()` method. + +Since some queries take parameters, you will need to pass a placeholder value when registering the query. + +```ts +import { param } from '@synthql/queries'; +import { from } from '../generated'; + +const findUserById = (id: number) => + from('users').columns('id', 'name', 'email').filter({ id }).first(); + +const queryEngine = new QueryEngine({ + url: 'postgresql://user:password@localhost:5432/dbname', +}); + +// Notice that we are passing a placeholder value of `0` for the +// `id` parameter. We could have passed any value, it is essentially +// telling the QueryEngine that the `id` is a parameter for the +// query, and can be replaced with any value. +queryEngine.registerQuery(findUserById(0)); + +// You can now invoke the query with any value +const result = await queryEngine.executeAndWait(findUserById(anyUserId)); +``` + +## Queries with conditional logic + +Some queries may include conditional logic. For example, you might want to alter the structure of the query based on the value of a parameter. + +```ts +const findUsersByStatus = (status: 'active' | 'inactive') => { + const query = from('users') + .columns('id', 'name', 'email') + .filter({ status }); + + if (status === 'active') { + // If the user is active, we want to return all users + return query.all(); + } else { + // If the user is not active, we want to return only + // the first 100 users as there might be too many. + return query.take(100); + } +}; +``` + +The problem with these types of queries is that they return two different query instances depending on the value of the `status` parameter. + +To register these queries correctly, you will need to register each variant individually. + +```ts +const queryEngine = new QueryEngine({ + url: 'postgresql://user:password@localhost:5432/dbname', +}); + +queryEngine.registerQueries([ + findUsersByStatus('active'), + findUsersByStatus('inactive'), +]); +``` + +## The security of registered queries + +:::note +This is an advanced topic. You don't need to understand this section to use SynthQL. +::: + +:::info +While the `QueryEngine` provides a `dangerouslyAllowUnregisteredQueries` option, using it bypasses the security benefits of query whitelisting and is strongly discouraged in production environments. +::: + +Since much of the security of SynthQL depends on registered queries, it’s important to understand how they work. + +The high-level idea is simple: + +1. **Identifying queries**: When a query is registered, we calculate a hash of the query. This hash is then used to identify the query. + +2. **Checking queries**: When a query is executed, the `QueryEngine` checks the hash of the query to ensure that it has been registered. + +3. **Parameter substitution**: When a query hash matches, the parameter values are substituted, and the query is executed. + +### Identifying queries + +Every query is given a hash upon creation. The `hash` is used to identify the query in the `QueryEngine`. + +When a query is registered, the `QueryEngine` calculates the `hash` and stores it in memory. + +### Checking queries + +Whenever a query is executed, the `QueryEngine` calculates the hash of the query and checks if it has been registered. If it has, the query is executed. If it has not, the query is rejected. + +### Parameter substitution + +When a query is executed, the `QueryEngine` substitutes the parameter values into the query and executes it. + +For example: + +```ts +const findUserById = (id: number) => from('users').filter({ id }).first(); + +// The `QueryEngine` will substitute the parameter values into the query and execute it. +queryEngine.registerQuery(findUserById(0)); +``` + +### What happens in case of a hash collision? + +If you try to register two different queries with the same hash, the `QueryEngine` will throw an error. + +If a malicious user tries to execute query A with the hash of query B, it will be equivalent to simply executing query B. + +### How is the hash calculated? + +The hash is calculated by `JSON.stringify()`ing the query and then hashing the result. We hash the stringified query to avoid sending potentially large JSON strings over the wire. Special care is taken to ensure that the parts of the query that are parameterizable are not hardcoded in the hash. + +For example, the following two queries will have the same hash: + +```ts +const queryA = from('users').filter({ active: true }).all(); +const queryB = from('users').filter({ active: false }).all(); +``` + +But these two will not: + +```ts +const queryC = from('users') + .columns('id', 'name', 'email') + .filter({ active: true }) + .all(); +const queryD = from('users').columns('email').filter({ active: true }).all(); +``` + +The query hash is calculated by the [`hashQuery`](https://github.com/synthql/SynthQL/blob/master/packages/queries/src/util/hashQuery.ts#L9) function. diff --git a/packages/docs/docs/300-security/200-query-permissions.md b/packages/docs/docs/300-security/200-query-permissions.md new file mode 100644 index 00000000..3946c627 --- /dev/null +++ b/packages/docs/docs/300-security/200-query-permissions.md @@ -0,0 +1,73 @@ +# Query permissions + +SynthQL uses a declarative approach to define the permissions required to run a query. + +With SynthQL, you don't need to sprinkle your code with permission assertions or `if()` conditions to check for permissions. Instead, you define, on a per-query basis, which permissions are required to run the query, and the `QueryEngine` takes care of the rest. + +This approach offers both simplicity and robust security control. + +## Defining permissions + +The `.permissions()` method is used to define which permissions are required to run a query. + +```ts +from('users').permissions('users:read').all(); +``` + +The `.permissions(...listOfPermissions: string[])` method takes a list of permissions, where each permission is a string. + +```ts +from('users').permissions('users:read', 'users:write').all(); +``` + +You can use a TypeScript enum to define the list of permissions and gain extra type safety. + +```ts +enum Permissions { + usersRead = 'users:read', + usersWrite = 'users:write', +} + +const query = from('users') + .permissions(Permissions.usersRead, Permissions.usersWrite) + .all(); +``` + +## Permission inheritance + +When you include a subquery, the permissions accumulate. Users must have all permissions from both parent and subquery to execute the combined query. + +```ts +const pets = from('pets') + .permissions('pets:read') + .filter({ owner_id: col('users.id') }) + .all(); + +const query = from('users').permissions('users:read').include({ pets }).all(); +``` + +In this example, the user needs to have both the `users:read` and `pets:read` permissions to execute the query. + +## Query context + +When you execute a query, you can pass a `context` object. This object is used to provide additional information to the query, such as the user's permissions. + +```ts +// You want to generate this from some source, e.g. parsing the cookie sent with a HTTP request +const context = { permissions: ['users:read', 'pets:read'] }; + +// Execute the query +const result = await queryEngine.executeAndWait(query, { context }); +``` + +## The security of query permissions + +:::info +While the `QueryEngine` provides a `dangerouslyIgnorePermissions` option, using it bypasses the security benefits of query permissioning and is strongly discouraged in production environments. +::: + +The `QueryEngine` will traverse the query recursively and reject it unless it meets all the ACL requirements. However, if you don't want these permissions (ACL requirements) to be checked, you can set the `dangerouslyIgnorePermissions` option when initializing the `QueryEngine`. + +## What of query whitelisting (i.e. `QueryEngine.registerQueries()`)? + +When a query is added to the whitelist using `registerQueries()`, it is registered along with its permissions. This ensures that a malicious client cannot modify the ACL requirements of the query. diff --git a/packages/docs/docs/300-security/300-query-middlewares.md b/packages/docs/docs/300-security/300-query-middlewares.md new file mode 100644 index 00000000..a0fe373a --- /dev/null +++ b/packages/docs/docs/300-security/300-query-middlewares.md @@ -0,0 +1,57 @@ +# Query middlewares + +Query middlewares are functions that are run before a query is executed. They can be used to add additional functionality to the query, such as logging, caching, or authentication. + +In the context of security, query middlewares can be used to add additional checks to every query or limit the result set. + +## Adding a middleware + +In this example, we're creating a middleware that will act on every query to the `payment` table and add a filter to the `customer_id` column. + +```ts +// index.ts +import { DB } from './generated'; +import { QueryEngine, middleware } from '@synthql/backend'; +import { Query } from '@synthql/queries'; +import { payments } from './queries'; + +// Create types & interface for context +type UserRole = 'user' | 'admin' | 'super'; + +type UserPermission = 'user:read' | 'admin:read' | 'super:read'; + +interface Session { + id: number; + email: string; + isActive: boolean; + roles: UserRole[]; + permissions: UserPermission[]; +} + +// Create middleware +const restrictPaymentsByCustomer = middleware, Session>({ + predicate: ({ query, context }) => + query?.from === 'payments' && + context?.roles?.includes('user') && + context?.isActive, + transformQuery: ({ query, context }) => ({ + ...query, + where: { + ...query.where, + customer_id: context.id, + }, + }), +}); + +// Initialize query engine and register middleware +const queryEngine = new QueryEngine({ + url: 'postgresql://user:password@localhost:5432/dbname', + middlewares: [restrictPaymentsByCustomer], +}); +``` + +## When are middlewares executed? + +When a query is executed, a hash check is performed first (if query whitelisting is enabled), followed by the substitution of any parameterized fields. After that, the middleware is executed. + +This ensures that the middleware can inject additional parameters into the query, as it is now happening in a safe context. diff --git a/packages/docs/docs/300-security/index.md b/packages/docs/docs/300-security/index.md index 9067f687..8a608e3e 100644 --- a/packages/docs/docs/300-security/index.md +++ b/packages/docs/docs/300-security/index.md @@ -4,10 +4,14 @@ Learn the basics of security in SynthQL. [Read more](./security/introduction). -## Query middleware +## Query whitelisting -Learn how to use query middlewares to add additional security checks to your queries. [Read more](./security/query-middleware). +Learn how to use query whitelisting to add SynthQL queries to a whitelist and configure it so that only whitelisted queries can be executed. [Read more](./security/query-whitelisting). ## Query permissions Learn how to add permissions to your queries, to implement ACL-like functionality. [Read more](./security/query-permissions). + +## Query middlewares + +Learn how to use query middlewares to add additional security checks to your queries. [Read more](./security/query-middlewares). diff --git a/packages/docs/docs/300-security/query-middleware.md b/packages/docs/docs/300-security/query-middleware.md deleted file mode 100644 index bf485ce3..00000000 --- a/packages/docs/docs/300-security/query-middleware.md +++ /dev/null @@ -1,43 +0,0 @@ -# Query middlewares - -Query middlewares are functions that are executed before a query is executed. They can be used to add additional functionality to the query, such as logging, caching, or authentication. - -In the context of security, query middlewares can be used to add additional checks on every query, or limit the result set. - -## Adding a middleware - -You can add a middleware to the query engine as follows: - -```ts -import { DB } from './db'; -import { QueryEngine, mapQuery } from '@synthql/backend'; -import { orders } from './queries'; - -const restrictOrdersByUser = middleware() - .from('orders') - .mapQuery((query, context) => { - const userId = context.user.id; - return { - context, - query: { - ...query, - // transforms the `where` to ensure that only orders can be read from the - // current user. - where: { - ...query.where, - user_id: userId, - }, - }, - }; - }); - -const queryEngine = new QueryEngine({ - middlewares: [restrictOrdersByUser], -}); -``` - -## When are middlewares executed? - -When a query is executed, the ID check is performed first, and then the parameters are substituted. Then the middleware is executed. - -This ensures that the middleware can inject additional parameters to the query as it's now happening in a safe context. diff --git a/packages/docs/docs/300-security/query-permissions.md b/packages/docs/docs/300-security/query-permissions.md deleted file mode 100644 index 5c40fdf1..00000000 --- a/packages/docs/docs/300-security/query-permissions.md +++ /dev/null @@ -1,60 +0,0 @@ -# Query permissions - -SynthQL uses a declarative approach to define what permissions are required to run a query. This approach is both simple and powerful, and makes it easy to understand what permissions are required to run a query. - -With SynthQL you don't need to sprinkle your code with permission assertions or `if` conditions to check for permissions, instead you define on a per-query basis what permissions are required to run the query, and the QueryEngine will take care of the rest. - -## Defining permissions - -The `.requires()` method is used to define what permissions are required to run a query. - -```ts -from('users').requires('users:read').all(); -``` - -The `.requires(...roles:string[])` method takes a list of roles. Roles can be any string. - -```ts -from('users').requires('users:read', 'users:write').all(); -``` - -You can use an TypeScript enum to define the list of permissions and get extra type safety: - -```ts -enum Permissions { - usersRead = 'users:read', - usersWrite = 'users:write', -} - -from('users').requires(Roles.usersRead, Roles.usersWrite).all(); -``` - -## Role inheritance - -When you include a sub-query, the permissions add up. This means the user needs to have all the permissions of both the parent and sub-query to be able to execute. - -```ts -const pets = from('pets') - .requires('pets:read') - .where({ owner_id: col('users.id') }) - .all(); - -from('users').requires('users:read').include({ pets }).all(); -``` - -In this example, the user needs to have the `users:read` and `pets:read` permissions to execute the query. - -## Query context - -When you execute a query, you can pass a `context` object. This object is used to pass additional information to the query, such as the user's permissions. - -```ts -const context = { permissions: ['users:read', 'pets:read'] }; -queryEngine.execute(query, { context }); -``` - -The query engine will traverse the query recursively and reject the query unless it meets all the ACL requirements. - -## Query registration - -When a query is registered, it is registered along with its permissions. This means a malicious client cannot modify the ACL requirements of a query. diff --git a/packages/docs/docs/300-security/registered-queries.md b/packages/docs/docs/300-security/registered-queries.md deleted file mode 100644 index f9600b50..00000000 --- a/packages/docs/docs/300-security/registered-queries.md +++ /dev/null @@ -1,149 +0,0 @@ -# Registered queries - -One of the core security goals of SynthQL is to be out-of-the-box secure. This means that by default the QueryEngine will not execute unknown queries. Queries need to be explicitly registered with the `QueryEngine` for them to be executed. - -```ts -const findAllActiveUsers = () => from('users') - .columns('id','name','email') - .filter({active: true}) - .all() - -const queryEngine = new QueryEngine({...}) - -// Register the query -queryEngine.registerQuery(findAllActiveUsers()) - -// The QueryEngine will now only execute the registered queries -queryEngine.execute(findAllActiveUsers(), { context }) -``` - -## Why registered queries? - -Registered queries are a security feature that ensures that only known queries are executed. This prevents a potentially malicious actor from executing arbitrary queries on your database. - -When you build a traditional REST API, you implicitly "register queries" by defining the endpoints that are available. As SynthQL is more dynamic, we need an explicit mechanism to indicate that a query was authored in a safe context. - -## How to register queries - -Registering queries is simple. All you need to do is pass the query to the `QueryBuilder#registerQueries` or `QueryEngine#registerQuery` methods. - -As some queries take parameters, you will need to pass a placeholder value when you register the query. - -```ts -import { param } from '@synthql/queries' -import { from } from "../generated"; - -const findUserById = (id: number) => from('users') - .columns('id','name','email') - .filter({id}) - .first() - -const queryEngine = new QueryEngine({...}) - -// Notice that we are passing a placeholder value of `0` for the -// `id` parameter. We could have passed any value, it is essentially -// telling the QueryEngine that the `id` is a parameter for the -// query, and can be replaced with any value. -queryEngine.registerQuery(findUserById(0)) - -// You can now invoke the query with any value -queryEngine.execute(findUserById(anyUserId)) -``` - -## Queries with conditional logic - -Some queries may have conditional logic. For example, you may want to alter the structure of the query based on the value of a parameter. - -```ts -const findUsersByStatus = (status: 'active' | 'inactive') => { - const query = from('users') - .columns('id', 'name', 'email') - .filter({ status }); - - if (status === 'active') { - // If the user is active, we want to return all users - return query.all(); - } else { - // If the user is not active, we want to return only - // the first 100 users as there might be too many. - return query.take(100); - } -}; -``` - -The problem with these types of queries is that they actually return two different query instances depending on the value of the `status` parameter. - -To register these types of queries correctly, you will need to register each variant of the query individually. - -```ts -const queryEngine = new QueryEngine({...}) - -queryEngine.registerQueries([ - findUsersByStatus('active'), - findUsersByStatus('inactive') -]) -``` - -## The security of registered queries - -> Note: this is an advanced topic. You don't need to understand this section to use SynthQL. - -As a lot of the security of SynthQL depends on the registered queries, it is important to understand how they work. - -The high level idea is simple: - -1. Identifying queries: When a query is registered, we calculate a hash of the query. This hash is then used to identify the query. -2. Checking queries: When a query is executed, the QueryEngine will check the hash of the query to ensure that it has been registered. -3. Parameter substitution: When a query hash matches, the parameter values are substituted and the query is executed. - -### Identifying queries - -Every query is given a hash upon creation. The combination of `hash` and `name` is used to identify the query in the QueryEngine. - -When a query is registered, the QueryEngine calculates an ID based on the `hash` and `name` and stores it in memory. - -### Checking queries - -Whenever a query is executed, the QueryEngine will calculate the ID of the query and check if it has been registered. If it has, the query is executed. If it has not, the query is rejected. - -### Parameter substitution - -When a query is executed, the QueryEngine will substitute the parameter values into the query and execute it. - -Example: - -```ts -const findUserById = (id: number) => from('users').filter({ id }).first(); - -// The QueryEngine will substitute the parameter values into the query and execute it. -queryEngine.registerQuery(findUserById(0)); -``` - -### What happens in case of a hash collision? - -If you try to register two different queries with the same hash, the QueryEngine will throw an error. - -If a malicious user tries to execute a query A with the hash of query B, it would be equivalent to simply trying to execute query B. - -### How is the hash calculated? - -The hash is calculated by `JSON.stringify`ing the query, and then hashing the result. We hash the stringified query to avoid sending possibly very large JSON strings over the wire. Special care is taken to ensure that the parts of the query that are parameterizable are not hardcoded in the hash. - -So for example, the following two queries will have the same hash: - -```ts -const queryA = from('users').filter({ active: true }).all(); -const queryB = from('users').filter({ active: false }).all(); -``` - -But these two will not: - -```ts -const queryC = from('users') - .columns('id', 'name', 'email') - .filter({ active: true }) - .all(); -const queryD = from('users').columns('email').filter({ active: true }).all(); -``` - -The Query hash is calculated by the [`hashQuery`](https://github.com/synthql/SynthQL/blob/master/packages/queries/src/util/hashQuery.ts#L9) function. diff --git a/packages/docs/docs/500-deferred-queries.md b/packages/docs/docs/500-deferred-queries.md index c20418d4..ab5311a1 100644 --- a/packages/docs/docs/500-deferred-queries.md +++ b/packages/docs/docs/500-deferred-queries.md @@ -2,43 +2,42 @@ ## The bigger the query, the longer the latency -One of the disadvantages of large query trees is that they result in proportionally longer latencies. The reason is simple: you have to wait for the entire query tree to load before you can send the response back to the client. +One of the disadvantages of large query trees is that they result in proportionally longer latencies. The reason is simple: you have to wait for the entire query tree to load before you can send the response back to the client. So the bigger the query, the longer the wait time. -So the bigger the query, the longer the wait time. - -To mitigate this issue, SynthQL lets you mark parts of your query tree with `.defer()`. A deferred boundary will split your query into two and will tell the QueryEngine to flush results to the client in sequences. +To mitigate this issue, SynthQL allows you to mark parts of your query tree with `.defer()`. A `.defer()` boundary will split your query into two and will tell the `QueryEngine` to flush results to the client in sequences. This feature is similar to [GraphQL's @defer directive](https://graphql.org/blog/2020-12-08-improving-latency-with-defer-and-stream-directives/). -## Example: Store with many products +## Example: store with many products -Let's imagine that you have a store that can sell hundreds of different products. You need to implement a Store page in which you display the store's properties and after scrolling a bit the user can see a list of all the products sold by the store. +Let’s imagine that you have a store that sells hundreds of different products. You need to implement a "Store" page that displays the store's properties, and after scrolling down a bit, the user can see a list of all the products sold by the store. -To improve the latency of this page, you can mark the `products` query as `.defer()` as follows: +To improve the latency of this page, you can mark the `products` query with `.defer()`, as follows: ```tsx const products = from('products') - .column('id', 'price', 'name') - .defer() // <======= this marks the products query as deferred - .many(); + .column('id', 'name', 'price') + .defer() // <======= this marks the `products` query to be deferred during execution + .all(); const query = from('store').column('store_name', 'store_owner').include({ products, }); -useSynthql(query); +// Execute the query +const result = queryEngine.execute(query); ``` -Marking the `products` subquery as `defer` will result in the query client first fetching the `store`, and then re-rendering the component when eventually the data from the `products` comes in. +Marking the `products` subquery with `.defer()` will result in the query client first fetching the `store`, and then re-rendering the component once the data from the `products` comes in. ## What happens over the wire -When the `QueryEngine` executes a query, it will flush results 'early' to the client, whenever it sees a `.defer()` boundary. In this example this will result in two lines of JSON being sent to the client over the same HTTP connection, as seen below: +When the `QueryEngine` executes a query, it will flush results 'early' to the client whenever it encounters a `.defer()` boundary. In this example, this will result in two lines of JSON being sent to the client over the same HTTP connection, as shown below: ```json -// First line of JSON -{"store_name": "Fun Inc.", "store_owner": "Bob", "products": {"status":"pending"}} +// First line of JSON: +[{ "store_name": "Toys Inc.", "store_owner": "Bob", "products": { "status": "pending" }}] -// Once the products have loaded -{"store_name": "Toys Inc.", "store_owner": "Bill", "products": {"status":"done", "data": [...]}} +// Once the products have loaded: +[{ "store_name": "Toys Inc.", "store_owner": "Bob", "products": { "status": "done", "data": [{ "id": 1, "name": "Shoe", "price": 199 }] }}] ``` diff --git a/packages/docs/docs/800-custom-providers.md b/packages/docs/docs/800-custom-providers.md index 55277fd4..733dd9d5 100644 --- a/packages/docs/docs/800-custom-providers.md +++ b/packages/docs/docs/800-custom-providers.md @@ -1,44 +1,102 @@ # Custom query providers +:::caution WIP +This page is a work in progress and subject to changes. +::: + While SynthQL is designed for database queries, it can also work with other data sources. Custom query providers allow you to use specific functions to fetch data from non-database sources as part of your query. -This can be used to fetch data from a REST endpoint, a file or any other data source you can imagine. +This can be used to fetch data from a REST endpoint, a file, or any other data source you can imagine. -## How can I configure a custom provider +## How can I configure a custom provider? -When constructing a `QueryEngine` you may pass a list of `providers`. In this example we're configuring a custom provider for the `rotten_tomatoes_rating` table. +When constructing a `QueryEngine`, you can pass a list of `providers`. In this example, we're configuring a custom provider for the `rotten_tomatoes_rating` table. ```ts import { QueryProvider } from "@synthql/backend"; interface DB { - film: {...} - rotten_tomatoes_rating: {...} + film: { + columns: { + id: { + type: number; + selectable: true; + includable: true; + whereable: true; + nullable: false; + isPrimaryKey: true; + }; + title: { + type: string; + selectable: true; + includable: true; + whereable: true; + nullable: false; + isPrimaryKey: false; + }; + last_update: { + type: string; + selectable: true; + includable: true; + whereable: true; + nullable: false; + isPrimaryKey: false; + }; + }; + }, + rotten_tomatoes_rating: { + columns: { + title: { + type: string; + selectable: true; + includable: true; + whereable: true; + nullable: false; + isPrimaryKey: false; + }; + rating: { + type: number; + selectable: true; + includable: true; + whereable: true; + nullable: false; + isPrimaryKey: true; + }; + last_update: { + type: string; + selectable: true; + includable: true; + whereable: true; + nullable: false; + isPrimaryKey: false; + }; + }; + } } const rottenTomatoesRatingProvider: QueryProvider = { table: 'rotten_tomatoes_rating'. execute: ({title}) => { - return fetchRottenTomatoesRatingByTitle(title) + return fetchRottenTomatoesRatingByTitle(title) // custom data fetching logic should be inside here } } -new QueryEngine({ - executors: [rottenTomatoesRatingProvider] -}) +const queryEngine = new QueryEngine({ + providers: [rottenTomatoesRatingProvider] +}); ``` -This lets you build queries like: +This allows you build queries like: ```ts export function findFilm(id: number) { const rating = from('rotten_tomatoes_rating') .columns('title', 'rating') - .where({ title: col('film.title') }) - .maybe(); + .filter({ title: col('film.title') }) + .first(); - return from('film').columns('id', 'title').include({ rating }).many(); + return from('film').columns('id', 'title').include({ rating }).all(); } ``` -The query engine will send the `film` query to the database, and the `rotten_tomatoes_rating` query to the query executor. +The `QueryEngine` will send the `film` query to the database, and the `rotten_tomatoes_rating` query to the query executor. diff --git a/packages/docs/docs/900-architecture.md b/packages/docs/docs/900-architecture.md index 87bddee9..c8778512 100644 --- a/packages/docs/docs/900-architecture.md +++ b/packages/docs/docs/900-architecture.md @@ -10,15 +10,15 @@ SynthQL is composed of 7 packages: 1. `@synthql/backend`: contains the query engine, `QueryEngine`, which executes SynthQL queries. You will usually want to use this inside an HTTP server, and send queries from your client apps via HTTP request, but you can also use it to execute SynthQL queries directly (inside a Node.js script). -1. `@synthql/react`: contains a [TanStack Query](https://tanstack.com/query/latest/docs/framework/react/installation) client, `useSynthql`, that can be used in a React or "React-based" framework app, to send SynthQL queries to an HTTP server instance of the query engine. +1. `@synthql/react`: contains a [TanStack Query](https://tanstack.com/query/latest/docs/framework/react/installation) client, `useSynthql()`, that can be used in a React or "React-based" framework app, to send SynthQL queries to an HTTP server instance of the query engine. 1. `@synthql/handler-express`: contains a handler function, `createExpressSynthqlHandler`, that you can use in [Express.js](https://expressjs.com/en/starter/installing.html) apps to parse and execute SynthQL queries sent over HTTP. 1. `@synthql/handler-next`: contains a handler function, `createNextSynthqlHandler`, that you can use in [Next.js](https://nextjs.org/docs/14/getting-started/installation) apps to parse and execute SynthQL queries sent over HTTP. -1. `@synthql/cli`: contains a CLI that allows you to generate the TypeScript types and schema files for your database. +1. `@synthql/cli`: contains a CLI that you can use to generate the TypeScript types and schema files for your database. -1. `@synthql/introspect`: contains a generator function, `generate`, that generates the database types and schema definitions; the same function that the CLI wraps. You can use this function to generate the same output without the CLI wrapper. +1. `@synthql/introspect`: contains a generator function, `generate()`, that generates the database types and schema definitions; the same function that the CLI wraps. You can use this function to generate the same output without the CLI wrapper. These are the dependencies between the packages: @@ -26,7 +26,7 @@ These are the dependencies between the packages: ## Information flow -This diagram shows how information flows through SynthQL. Requests are made by the client using `useSynthql`, which just sends the query over to the `POST /synthql` endpoint. +This diagram shows how information flows through SynthQL. Requests are made by the client using `useSynthql()`, which just sends the query over to the `POST /synthql` endpoint. This then feeds the query to the handler, which parses the query from the request object, and sends it to the `QueryEngine`, which eventually compiles the query down to plain SQL and in turn sends it to the connected PostgreSQL database. diff --git a/packages/docs/src/pages/index.tsx b/packages/docs/src/pages/index.tsx index d5836a81..5489c1db 100644 --- a/packages/docs/src/pages/index.tsx +++ b/packages/docs/src/pages/index.tsx @@ -1,12 +1,12 @@ import CodeBlock from '@theme/CodeBlock'; -import Link from '@docusaurus/Link'; -import Layout from '@theme/Layout'; import Heading from '@theme/Heading'; +import Layout from '@theme/Layout'; +import Link from '@docusaurus/Link'; export default function Home(): JSX.Element { return (
-

What is SynthQL

+

What is SynthQL?

- SynthQL is a full stack HTTP client for your - PostgreSQL database. It lets you declaratively - describe your React component's data + SynthQL is a full-stack HTTP client for your + PostgreSQL database. It allows you declaratively + describe your client components' data dependencies.

- With SynthQL you can focus on building great - products instead of spending time thinking how - to most efficiently fetch data into your + With SynthQL, you can focus on building great + products instead of spending time thinking about + how to efficiently fetch data into your components.

SynthQL reads your PostgreSQL database schema - and generates types so you get type safety end - to end. + and generates types, so you get type safety + end-to-end.

{[ + `// Compose your query using the type-safe query builder`, `const q = from('movies')`, ` .columns('id', 'title')`, ` .filter({ id: 1 })`, - ` .take(10);`, + ` .take(2);`, ``, - `// Execute the query`, + `// Executing the query (using the React query client)`, `const { data: movies } = useSynthql(q);`, ``, - `// movies is now `, - `// Array<{id: string, title:string}> `, - `console.log(movies[0].id)`, + `console.log(movies);`, + `// Will print:`, + `[`, + ` { id: 1, title: 'The Empire Strikes Back' },`, + `];`, ].join('\n')}
@@ -168,6 +171,8 @@ export default function Home(): JSX.Element { style={{ display: 'flex', flexDirection: 'column', + justifyContent: 'center', + alignItems: 'center', gap: 16, transform: 'translateY(-20%)', }} @@ -194,50 +199,76 @@ const features: Array<{ { title: 'End-to-end type safety', description: - 'Generate types from your schema with a single command. Run in on your CI to ensure types are always up to date.', - link: '/docs/getting-started#generate-types', - code: [`npx @synthql/cli generate --url $DATABASE_URL`].join('\n'), + 'Generate types from your schema with a single command. These types will then power the type-safety provided by the query builder. You should run this command on your CI to ensure that the types are always up to date.', + link: '/docs/generating-types', + code: [ + `// Running the command:`, + `npx @synthql/cli generate --url $DATABASE_URL`, + ``, + `// generates a database types file like:`, + ` +export interface DB { + actor: { + columns: { + actor_id: { + type: number; + selectable: true; + includable: true; + whereable: true; + nullable: false; + isPrimaryKey: true; + }; + name: { + type: string; + selectable: true; + includable: true; + whereable: true; + nullable: false; + isPrimaryKey: false; + }; + }; + } +} + +// which in turn powers a query builder, \`from()\`, +// which you can use to create SynthQL queries like: + +const q = from('actor') + .columns('actor_id', 'name') + .filter({ actor_id: 1 }) + .first(); + `, + ].join('\n'), }, { title: 'Composable query language', description: - 'Build complex queries by composing smaller queries together. The SynthQL query language is designed for easy composition and re-use.', + 'Build complex queries by composing smaller queries. The SynthQL query language is designed for easy composition and reusability.', + link: '/docs/query-language/composition', code: [ `const findPetsByOwner = (owner) =>`, ` from('pets')`, ` .filter({ owner })`, - ` .many();`, + ` .all();`, ``, `const findPersonById = (id) => {`, - ` const pets = findPetsByOwner(id)`, + ` const pets = findPetsByOwner(id);`, + ``, ` return from('people')`, ` .filter({ id })`, ` .include({ pets })`, - ` .one()`, - ` }`, - ].join('\n'), - }, - { - title: 'Built-in pagination & streaming', - code: [ - ` -const query = from('users') - .filter({age: {gt:18}}) - .take(100) // set the size of the page - -const {data, fetchNextPage} = useSynthql(query)`, - ].join('\n'), - description: [ - `Pagination in SynthQL just works! You don't need to do anything special to enable it!`, + ` .firstOrThrow();`, + `};`, ].join('\n'), }, { - title: 'Lazy queries', + title: 'Deferred queries', description: [ - `As queries become bigger, latency also grows. Lazy queries help you split large object graphs to optimize page load.`, + `As queries grow in size, latency increases. Deferred queries allow you to split large object graphs, optimizing page load times.`, '', - 'In the following example, we use a lazy query to load a store and its products separately. This means the store can load quickly and the products can load in the background.', - 'This is especially useful when the products are not immediately visible on the page.', + 'In the following example, we use a deferred query to load the store and its products separately. This allows the store to load quickly, while the products load in the background.', + '', + `This is particularly useful when the products aren't immediately visible on the page.`, ].join('\n'), link: '/docs/deferred-queries', code: ` @@ -246,8 +277,8 @@ const products = from('products') .filter({ product_id: { in: col('store.product_ids') } }) - .lazy() - .many() + .defer() // <<== Marks the subquery as 'deferred' + .all(); const query = from('store') .column('id', 'name') @@ -255,56 +286,86 @@ const query = from('store') .include({ products }) + .all(); -// Over the network, this results in two JSON lines -[{ id: "store 1", name: "Fancy store", products: { status: 'pending' } }] -[{ id: "store 1", name: "Fancy store", products: { status: "done", data: [...] } }] - `, +/* This returns two JSON lines */ + +// First line of JSON: +[ + { + "id": "1", + "name": "Fancy store", + "products": { + "status": "pending" + } + } +] + +// Once the products have loaded: +[ + { + "id": "1", + "name": "Fancy store", + "products": { + "status": "done", + "data": [ + { + "id": "1", + "name": "Shoe", + "price": 199 + } + ] + } + } +] + `, }, { title: 'Security', - link: '/docs/security', + link: '/docs/security/introduction', description: - 'SynthQL offers a number of security features to help you secure your application. This includes built-in authentication, query whitelisting, and more.', + 'SynthQL offers several security features to help secure your application, including built-in authentication, query whitelisting, and more.', code: ` const findPetsByOwner = (ownerId) => { return from('pets') - .column('name','id') + .column('name', 'id') .filter({ ownerId }) - .requires('pets:read') - .many() -} + .permissions('users:read', 'pets:read') + .all(); +}; const findPersonByIds = (ids) => { return from('people') .column('first_name','last_name') - .requires('person:read') - .filter({id:{in:ids}}) + .filter({ id: { in: ids }}) + .permissions('person:read') .include({ films: findPetsByOwner(col('people.id')) }) - .many() -}`, + .all(); +}; +`, }, { title: 'Custom query providers', link: '/docs/custom-providers', description: - 'Not all data comes from the database. Use custom providers to join your DB tables with data from 3rd party APIs using a predictable performance model.', + 'Not all data comes from the database. Use custom providers to join your database tables with data from third-party APIs, ensuring predictable performance.', code: ` const findFilmsWithRatings = () => { const ratings = from('rotten_tomatoes_ratings') .filter({ - year:col('film.year') + year: col('film.year') }) - .many() + .all(); return from('films') .filter({ year: 1965 }) .include({ ratings }) - .many() -}`, + .all(); +}; +`, }, ]; diff --git a/packages/docs/static/reference/assets/navigation.js b/packages/docs/static/reference/assets/navigation.js index 699db3da..38c28662 100644 --- a/packages/docs/static/reference/assets/navigation.js +++ b/packages/docs/static/reference/assets/navigation.js @@ -1 +1 @@ -window.navigationData = "data:application/octet-stream;base64,H4sIAAAAAAAAA5WXW2/TMBSA/0ueC2ODDehb11ZigLbSTiA0TZPnnK7WXCe1ndEK8d9RnbR2YufYe5zy+fPlXHZ69zfTsNXZMHsk9BlEfqIkzQZZSfQqG2brIq84qJPm44OS9O1Kr3k2yJ6ZyLPh2SCjK8ZzCSIb3h1lPyqQu6l4YgKsjHKiVEfmgG3x6dmnf4Ojb7ETerXhUykLiQtdEjM6G89kUSprZUKDXBLaf1KzoPMO5xdd+YwTkWbdk5iOFpwD1d+J0la4rATVrBBtn4O2jRcfWsJ1WSgwm8eNlkWUa5bnHP4QCTGhJT3dvXtGzsK5SDkL5KGzckVEzkG+gW0pQamwpYEeGig5s6kEomFar2qS7UvtCl08tA2iQF/kcC8BW41fak8k3+gatrrvInpXhrT+ks5O7z5/PD13Y+KvmMOmAjehk/dqVsa2rJ8Zu50fpuOufYvRADGhZaFKoD3hsd+Tg/MEAiTRwaLq6A4oesRNBZJBT000H5MPNy54tRYTWAbbnGs7klibO0L7/gpSMwh35aDYrkna4hVmVLigK1iTqKzGMNEteeSQ8pQHEJONxG5cCPNHp7xck6VipTQSu8llRDW5TLB0/uMERYH/NEGXeYiIyzCprtbr9+v8t/eMl0wQubspMd2BSXXta7vwWnPIWJMx75jInAnCmUYD4mBRo6kYVGaINM/trkSja6k030/CqwShwWLGCSxBSsjnoCqO1libjHmvBOVVjp6yQWKmrwUTeP7VRMwTLdikaq2nXCLJGrQ/Xni6I5nkjQfBwV5hjCVgB42Z57DEA2KAmGUB+/Ee09REzBNtnkmd89cKJGoxQJJlzEml4q4ai/bMq+vR/PfDzWw6H93ezBdW+0Ik29+s0zk7fNv/vjVVFjw0i7k2WnDkhxJTfdUQtnV5VN1Jsj5jINVaonK/WUxjIESy6ft16UoM9OYUnVklkL6J2nxKnlebUd4bjZxJywrbMDoD1qQJ0zfwuqWnPICxLFY9x7U5bNVttvugrQxuTjGTxQvLw/nnHfkAo8nX+w4hsYcjavUacQcO5JYVVwqaU+BOyyXJvIjhzmCKmRq4/w+guVtDnxMAAA==" \ No newline at end of file +window.navigationData = "data:application/octet-stream;base64,H4sIAAAAAAAAE5WXW2/TMBSA/0ueC2ODDehb11ZigLbSTiA0TZPnnK7WXCe1ndEK8d9RnbR2YufYe5zy+fPlXHZ69zfTsNXZMHsk9BlEfqIkzQZZSfQqG2brIq84qJPm44OS9O1Kr3k2yJ6ZyLPh2SCjK8ZzCSIb3h1lPyqQu6l4YgKsjHKiVEfmgG3x6dmnf4Ojb7ETerXhUykLiQtdEjM6G89kUSprZUKDXBLaf1KzoPMO5xdd+YwTkWbdk5iOFpwD1d+J0la4rATVrBBtn4O2jRcfWsJ1WSgwm8eNlkWUa5bnHP4QCTGhJT3dvXtGzsK5SDkL5KGzckVEzkG+gW0pQamwpYEeGig5s6kEomFar2qS7UvtCl08tA2iQF/kcC8BW41fak8k3+gatrrvInpXhrT+ks5O7z5/PD13Y+KvmMOmAjehk/dqVsa2rJ8Zu50fpuOufYvRADGhZaFKoD3hsd+Tg/MEAiTRwaLq6A4oesRNBZJBT000H5MPNy54tRYTWAbbnGs7klibO0L7/gpSMwh35aDYrkna4hVmVLigK1iTqKzGMNEteeSQ8pQHEJONxG5cCPNHp7xck6VipTQSu8llRDW5TLB0/uMERYH/NEGXeYiIyzCprtbr9+v8t/eMl0wQubspMd2BSXXta7vwWnPIWJMx75jInAnCmUYD4mBRo6kYVGaINM/trkSja6k030/CqwShwWLGCSxBSsjnoCqO1libjHmvBOVVjp6yQWKmrwUTeP7VRMwTLdikaq2nXCLJGrQ/Xni6I5nkjQfBwV5hjCVgB42Z57DEA2KAmGUB+/Ee09REzBNtnkmd89cKJGoxQJJlzEml4q4ai/bMq+vR/PfDzWw6H93ezBdW+0Ik29+s0zk7fNv/vjVVFjw0i7k2WnDkhxJTfdUQtnV5VN1Jsj5jINVaonK/WUxjIESy6ft16UoM9OYUnVklkL6J2nxKnlebUd4bjZxJywrbMDoD1qQJ0zfwuqWnPICxLFY9x7U5bNVttvugrQxuTjGTxQvLw/nnHfkAo8nX+w4hsYcjavUacQcO5JYVVwqaU+BOyyXJvIjhzmCKmRq4/w+guVtDnxMAAA==" \ No newline at end of file diff --git a/packages/docs/static/reference/assets/search.js b/packages/docs/static/reference/assets/search.js index 8cdb0298..34fdabc1 100644 --- a/packages/docs/static/reference/assets/search.js +++ b/packages/docs/static/reference/assets/search.js @@ -1 +1 @@ -window.searchData = "data:application/octet-stream;base64,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"; \ No newline at end of file +window.searchData = "data:application/octet-stream;base64,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"; \ No newline at end of file diff --git a/packages/handler-express/README.md b/packages/handler-express/README.md index 2480aa35..e82e3ad2 100644 --- a/packages/handler-express/README.md +++ b/packages/handler-express/README.md @@ -7,7 +7,7 @@ SynthQL-compatible route handler function for use in [Express.js](https://expres import { QueryEngine } from '@synthql/backend'; export const queryEngine = new QueryEngine({ - url: process.env.DATABASE_URL, + url: 'postgresql://user:password@localhost:5432/dbname', }); // src/index.ts diff --git a/packages/handler-next/README.md b/packages/handler-next/README.md index dc29d04b..78854d38 100644 --- a/packages/handler-next/README.md +++ b/packages/handler-next/README.md @@ -7,7 +7,7 @@ SynthQL-compatible route handler function for use in [Next.js](https://nextjs.or import { QueryEngine } from '@synthql/backend'; export const queryEngine = new QueryEngine({ - url: process.env.DATABASE_URL, + url: 'postgresql://user:password@localhost:5432/dbname', }); // src/app/[...synthql]/route.ts @@ -17,7 +17,7 @@ import { queryEngine } from '../../../queryEngine'; const nextSynthqlRequestHandler = createNextSynthqlHandler(queryEngine); export async function POST(request: Request) { - return await nextSynthqlRequestHandler(request); + return nextSynthqlRequestHandler(request); } ``` diff --git a/packages/queries/README.md b/packages/queries/README.md index 7fed9dbc..63aa3fdd 100644 --- a/packages/queries/README.md +++ b/packages/queries/README.md @@ -3,10 +3,10 @@ DSL for writing SynthQL queries. ```ts -import { from } from './generated.schema'; +import { from } from './generated'; const findUserById = (id: number) => - from('users').columns('id', 'first_name').where({ id }).many(); + from('users').columns('id', 'first_name').filter({ id }).all(); ``` ## Links diff --git a/packages/queries/src/QueryBuilderError.ts b/packages/queries/src/QueryBuilderError.ts index f9a14ea9..5e340a95 100644 --- a/packages/queries/src/QueryBuilderError.ts +++ b/packages/queries/src/QueryBuilderError.ts @@ -23,7 +23,7 @@ export class QueryBuilderError extends Error { `The table "${query.from}" is including table "${nestedQuery.from}",`, `but "${nestedQuery.from}" is missing a join predicate!`, '', - `Hint: are you missing \`.where({some_id: "${query.from}.some_id"})\``, + `Hint: are you missing \`.filter({ some_id: col("${query.from}.some_id") })\``, `on the "${nestedQuery.from}" query?`, ``, ]; diff --git a/packages/queries/src/validators/validateNestedQueriesHaveAValidRefOp.ts b/packages/queries/src/validators/validateNestedQueriesHaveAValidRefOp.ts index 281de11d..54ace855 100644 --- a/packages/queries/src/validators/validateNestedQueriesHaveAValidRefOp.ts +++ b/packages/queries/src/validators/validateNestedQueriesHaveAValidRefOp.ts @@ -3,7 +3,7 @@ import { Query } from '../types/types'; import { isRefOp } from './isRefOp'; /** - Validate that every included sub-query has at least one RefOp + Validate that every included subquery has at least one RefOp */ export function validateNestedQueriesHaveAValidRefOp(query: Query) { const nestedQueries = Object.values(query.include ?? {}); diff --git a/packages/react/README.md b/packages/react/README.md index c1a07982..1923e272 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -1,13 +1,17 @@ # @synthql/react -React client for SynthQL based on `tanstack/react-query`. +React client for SynthQL based on [TanStack Query](https://tanstack.com/query/latest/docs/framework/react/installation). ```ts import { from } from './generated'; -const query = from('users').columns('id', 'first_name').where({ id: 1 }).many(); +const query = from('users').columns('id', 'first_name').filter({ id: 1 }).all(); -useSynthql(query); +const { data: users } = useSynthql(query); + +console.log(users); +// Will print: +[{ id: 1, first_name: 'John' }]; ``` ## Links diff --git a/packages/react/src/useSynthql.test.tsx b/packages/react/src/useSynthql.test.tsx index b60b7c61..6f80f5ef 100644 --- a/packages/react/src/useSynthql.test.tsx +++ b/packages/react/src/useSynthql.test.tsx @@ -54,13 +54,11 @@ describe('useSynthql', () => { pagilaServer?.server.close(); }); - test('Fetching a single row from the Pagila database with all selectable columns auto-selected', async () => { - // @@start-example@@ Find a single actor by id with all selectable columns auto-selected - // @@desc@@ Finds 0 or 1 record(s) in the `actors` table where the `id` is in the list of ids and return all selectable columns + test('Fetching 1 row from the Pagila database, with all selectable columns auto-selected, and no filters specified', async () => { + // @@start-example@@ Find 1 actor, with all selectable columns auto-selected, and no filters specified + // @@desc@@ Finds 1 record in the `actor` table - const q = from('actor') - .where({ actor_id: { in: [1] } }) - .one(); + const q = from('actor').firstOrThrow(); // @@end-example@@ @@ -79,14 +77,11 @@ describe('useSynthql', () => { }); }, 1000); - test('Fetching 0 or 1 rows(s) from the Pagila database with columns to return specified', async () => { - // @@start-example@@ Find a single actor by id with columns to return specified` - // @@desc@@ Finds 0 or 1 record(s) in the `actors` table where the `id` is in the list of ids, and returns all selectable columns passed + test('Fetching 0 or 1 rows(s) from the Pagila database, with all selectable columns auto-selected, and no filters specified', async () => { + // @@start-example@@ Find 0 or 1 actor(s), with all selectable columns auto-selected, and no filters specified + // @@desc@@ Finds 0 or 1 record(s) in the `actor` table - const q = from('actor') - .columns('actor_id', 'first_name', 'last_name') - .where({ actor_id: { in: [1] } }) - .maybe(); + const q = from('actor').first(); // @@end-example@@ @@ -101,16 +96,17 @@ describe('useSynthql', () => { actor_id: 1, first_name: 'PENELOPE', last_name: 'GUINESS', + last_update: '2022-02-15 09:34:33+00', }); }, 1000); - test('Fetching a single row from the Pagila database with no filters specified', async () => { - // @@start-example@@ Find a single actor with no filters specified - // @@desc@@ Finds 0 or 1 record(s) in the `actors` table + test('Fetching 1 row from the Pagila database, with all selectable columns auto-selected', async () => { + // @@start-example@@ Find 1 actor by ID, with all selectable columns auto-selected + // @@desc@@ Finds 1 record in the `actor` table where the `actor_id` is in the list of IDs passed, and returns all selectable columns const q = from('actor') - .columns('actor_id', 'first_name', 'last_name') - .one(); + .filter({ actor_id: { in: [1] } }) + .firstOrThrow(); // @@end-example@@ @@ -125,17 +121,17 @@ describe('useSynthql', () => { actor_id: 1, first_name: 'PENELOPE', last_name: 'GUINESS', + last_update: '2022-02-15 09:34:33+00', }); }, 1000); - test('Fetching a single row from the Pagila database with offset value specified', async () => { - // @@start-example@@ Find a single actor with offset value specified - // @@desc@@ Finds 0 or 1 record(s) in the `actors` starting from the offset value position + test('Fetching 0 or 1 rows(s) from the Pagila database, with all selectable columns auto-selected', async () => { + // @@start-example@@ Find 0 or 1 actor(s) by ID, with all selectable columns auto-selected + // @@desc@@ Finds 0 or 1 record(s) in the `actor` table where the `actor_id` is in the list of IDs passed, and returns all selectable columns const q = from('actor') - .columns('actor_id', 'first_name', 'last_name') - .offset(1) - .one(); + .filter({ actor_id: { in: [1] } }) + .first(); // @@end-example@@ @@ -147,92 +143,26 @@ describe('useSynthql', () => { await result.waitFor(() => result.result.current.data !== undefined); expect(result.result.current.data).toEqual({ - actor_id: 2, - first_name: 'NICK', - last_name: 'WAHLBERG', - }); - }, 1000); - - test('Fetching a single row from the Pagila database with limit of results to return specified', async () => { - // @@start-example@@ Find a single actor with limit of results to return specified - // @@desc@@ Finds n record(s) in the `actors`, where `n` is the value passed to `limit()` - - const q = from('actor') - .columns('actor_id', 'first_name', 'last_name') - .limit(2) - .many(); - - // @@end-example@@ - - const result = renderSynthqlQuery({ - query: q, - server: pagilaServer, - }); - - await result.waitFor(() => result.result.current.data !== undefined); - - expect(result.result.current.data?.length).toEqual(2); - - expect(result.result.current.data).toEqual([ - { - actor_id: 1, - first_name: 'PENELOPE', - last_name: 'GUINESS', - }, - { - actor_id: 2, - first_name: 'NICK', - last_name: 'WAHLBERG', - }, - ]); - }, 1000); - - test('Fetching a single row from the Pagila database with number of results to take specified', async () => { - // @@start-example@@ Find a single actor with number of results to take specified - // @@desc@@ Finds n record(s) in the `actors`, where `n` is the value passed to `take()` - - const q = from('actor') - .columns('actor_id', 'first_name', 'last_name') - .take(2); - - // @@end-example@@ - - const result = renderSynthqlQuery({ - query: q, - server: pagilaServer, + actor_id: 1, + first_name: 'PENELOPE', + last_name: 'GUINESS', + last_update: '2022-02-15 09:34:33+00', }); - - await result.waitFor(() => result.result.current.data !== undefined); - - expect(result.result.current.data?.length).toEqual(2); - - expect(result.result.current.data).toEqual([ - { - actor_id: 1, - first_name: 'PENELOPE', - last_name: 'GUINESS', - }, - { - actor_id: 2, - first_name: 'NICK', - last_name: 'WAHLBERG', - }, - ]); }, 1000); - test('Fetching n rows from the Pagila database with columns to return specified', async () => { + test('Fetching `n` rows from the Pagila database with columns to return specified', async () => { const count = 10; const ids = Array(count) .fill(0) .map((_, i) => i + 1); - // @@start-example@@ Find all actors by ids columns to return specified` - // @@desc@@ Finds all the records in the `actors` table where their `id` is in the list of ids, and returns all selectable columns passed + // @@start-example@@ Find 0 through n actor(s) by IDs, with columns to return specified + // @@desc@@ Finds 0 through n record(s) in the `actor` table where their `actor_id` is in the list of IDs passed, and returns all selected columns const q = from('actor') .columns('actor_id', 'first_name', 'last_name') - .where({ actor_id: { in: ids } }) - .many(); + .filter({ actor_id: { in: ids } }) + .all(); // @@end-example@@ @@ -301,9 +231,98 @@ describe('useSynthql', () => { `); }, 1000); - test('Fetching a single result from the Pagila database with single-level-deep nested data', async () => { - // @@start-example@@ Find a single actor by id with a single-level-deep `include()` - // @@desc@@ Finds 1 record in the `customers` table where the `id` is in the list of ids + test('Fetching `n` rows from the Pagila database with `limit(n)` of results to return specified', async () => { + // @@start-example@@ Find 0 through n actor(s) with `limit(n)` of results to return specified + // @@desc@@ Finds 0 through n record(s) in the `actor` table, where `n` is the value passed to `limit()` + + const q = from('actor').limit(2).all(); + + // @@end-example@@ + + const result = renderSynthqlQuery({ + query: q, + server: pagilaServer, + }); + + await result.waitFor(() => result.result.current.data !== undefined); + + expect(result.result.current.data?.length).toEqual(2); + + expect(result.result.current.data).toEqual([ + { + actor_id: 1, + first_name: 'PENELOPE', + last_name: 'GUINESS', + last_update: '2022-02-15 09:34:33+00', + }, + { + actor_id: 2, + first_name: 'NICK', + last_name: 'WAHLBERG', + last_update: '2022-02-15 09:34:33+00', + }, + ]); + }, 1000); + + test('Fetching `n` rows from the Pagila database with number of results to `take(n)` (shorthand for `.limit(n).all()`) specified', async () => { + // @@start-example@@ Find 0 through n actor(s) with number of results to `take(n)` (shorthand for `.limit(n).all()`) specified + // @@desc@@ Finds 0 through n record(s) in the `actor` table, where `n` is the value passed to `take()` + + const q = from('actor').take(2); + + // @@end-example@@ + + const result = renderSynthqlQuery({ + query: q, + server: pagilaServer, + }); + + await result.waitFor(() => result.result.current.data !== undefined); + + expect(result.result.current.data?.length).toEqual(2); + + expect(result.result.current.data).toEqual([ + { + actor_id: 1, + first_name: 'PENELOPE', + last_name: 'GUINESS', + last_update: '2022-02-15 09:34:33+00', + }, + { + actor_id: 2, + first_name: 'NICK', + last_name: 'WAHLBERG', + last_update: '2022-02-15 09:34:33+00', + }, + ]); + }, 1000); + + test('Fetching 1 row from the Pagila database with `offset(n)` (offset value) specified', async () => { + // @@start-example@@ Find 1 actor with `offset(n)` (offset value) specified + // @@desc@@ Finds 1 record in the `actor` table, starting from the `offset(n)` (offset value) position + + const q = from('actor').offset(1).firstOrThrow(); + + // @@end-example@@ + + const result = renderSynthqlQuery({ + query: q, + server: pagilaServer, + }); + + await result.waitFor(() => result.result.current.data !== undefined); + + expect(result.result.current.data).toEqual({ + actor_id: 2, + first_name: 'NICK', + last_name: 'WAHLBERG', + last_update: '2022-02-15 09:34:33+00', + }); + }, 1000); + + test('Fetching 1 row from the Pagila database with single-level-deep nested data', async () => { + // @@start-example@@ Find 1 customer by ID with a single-level-deep `include()` + // @@desc@@ Finds 1 record in the `customers` table where the `actor_id` is in the list of IDs passed const store = from('store') .columns( @@ -312,10 +331,10 @@ describe('useSynthql', () => { 'manager_staff_id', 'last_update', ) - .where({ + .filter({ store_id: col('customer.store_id'), }) - .one(); + .firstOrThrow(); const q = from('customer') .columns( @@ -326,9 +345,9 @@ describe('useSynthql', () => { 'email', 'last_update', ) - .where({ customer_id: { in: [1] } }) + .filter({ customer_id: { in: [1] } }) .include({ store }) - .one(); + .firstOrThrow(); // @@end-example@@ @@ -355,9 +374,9 @@ describe('useSynthql', () => { }); }, 1000); - test('Fetching a single result from the Pagila database with two-level-deep nested data', async () => { - // @@start-example@@ Find a single customer by id with a two-level-deep `include()` - // @@desc@@ Finds 1 record in the `customers` table where the `id` is in the list of ids + test('Fetching 1 row from the Pagila database with two-level-deep nested data', async () => { + // @@start-example@@ Find 1 customer by ID with a two-level-deep `include()` + // @@desc@@ Finds 1 record in the `customers` table where the `actor_id` is in the list of IDs passed const address = from('address') .columns( @@ -367,10 +386,10 @@ describe('useSynthql', () => { 'district', 'last_update', ) - .where({ + .filter({ address_id: col('store.address_id'), }) - .one(); + .firstOrThrow(); const store = from('store') .columns( @@ -379,11 +398,11 @@ describe('useSynthql', () => { 'manager_staff_id', 'last_update', ) - .where({ + .filter({ store_id: col('customer.store_id'), }) .include({ address }) - .one(); + .firstOrThrow(); const q = from('customer') .columns( @@ -394,9 +413,9 @@ describe('useSynthql', () => { 'email', 'last_update', ) - .where({ customer_id: { in: [4] } }) + .filter({ customer_id: { in: [4] } }) .include({ store }) - .one(); + .firstOrThrow(); // @@end-example@@ @@ -431,16 +450,16 @@ describe('useSynthql', () => { }); }, 1000); - test('Fetching a single result from the Pagila database with three-level-deep nested data', async () => { - // @@start-example@@ Find a single customer by id with a three-level-deep `include()` - // @@desc@@ Finds 1 record in the `customers` table where the `id` is in the list of ids + test('Fetching 1 row from the Pagila database with three-level-deep nested data', async () => { + // @@start-example@@ Find 1 customer by ID with a three-level-deep `include()` + // @@desc@@ Finds 1 record in the `customers` table where the `actor_id` is in the list of IDs passed const city = from('city') .columns('city_id', 'country_id', 'city', 'last_update') - .where({ + .filter({ city_id: col('address.city_id'), }) - .one(); + .firstOrThrow(); const address = from('address') .columns( @@ -450,11 +469,11 @@ describe('useSynthql', () => { 'district', 'last_update', ) - .where({ + .filter({ address_id: col('store.address_id'), }) .include({ city }) - .one(); + .firstOrThrow(); const store = from('store') .columns( @@ -463,11 +482,11 @@ describe('useSynthql', () => { 'manager_staff_id', 'last_update', ) - .where({ + .filter({ store_id: col('customer.store_id'), }) .include({ address }) - .one(); + .firstOrThrow(); const q = from('customer') .columns( @@ -478,9 +497,9 @@ describe('useSynthql', () => { 'email', 'last_update', ) - .where({ customer_id: { in: [4] } }) + .filter({ customer_id: { in: [4] } }) .include({ store }) - .one(); + .firstOrThrow(); // @@end-example@@ @@ -521,24 +540,24 @@ describe('useSynthql', () => { }); }, 1000); - test('Fetching a single result from the Pagila database with four-level-deep nested data', async () => { - // @@start-example@@ Find a single customer by id with a four-level-deep `include()` - // @@desc@@ Finds 1 record in the `customers` table where the `id` is in the list of ids + test('Fetching 1 row from the Pagila database with four-level-deep nested data', async () => { + // @@start-example@@ Find 1 customer by ID with a four-level-deep `include()` + // @@desc@@ Finds 1 record in the `customers` table where the `actor_id` is in the list of IDs passed const country = from('country') .columns('country_id', 'country', 'last_update') - .where({ + .filter({ country_id: col('city.country_id'), }) - .one(); + .firstOrThrow(); const city = from('city') .columns('city_id', 'city', 'country_id', 'last_update') - .where({ + .filter({ city_id: col('address.city_id'), }) .include({ country }) - .one(); + .firstOrThrow(); const address = from('address') .columns( @@ -548,11 +567,11 @@ describe('useSynthql', () => { 'district', 'last_update', ) - .where({ + .filter({ address_id: col('store.address_id'), }) .include({ city }) - .one(); + .firstOrThrow(); const store = from('store') .columns( @@ -561,11 +580,11 @@ describe('useSynthql', () => { 'manager_staff_id', 'last_update', ) - .where({ + .filter({ store_id: col('customer.store_id'), }) .include({ address }) - .one(); + .firstOrThrow(); const q = from('customer') .columns( @@ -576,9 +595,9 @@ describe('useSynthql', () => { 'email', 'last_update', ) - .where({ customer_id: { in: [4] } }) + .filter({ customer_id: { in: [4] } }) .include({ store }) - .one(); + .firstOrThrow(); // @@end-example@@ diff --git a/packages/react/src/useSynthqlExamples.test.tsx b/packages/react/src/useSynthqlExamples.test.tsx index 7fc768a4..89dbab3b 100644 --- a/packages/react/src/useSynthqlExamples.test.tsx +++ b/packages/react/src/useSynthqlExamples.test.tsx @@ -29,8 +29,8 @@ describe('useSynthql test examples', () => { const q = from('users') .select({ id: true, name: true }) - .where({ id: { in: ['1'] } }) - .maybe(); + .filter({ id: { in: ['1'] } }) + .first(); const result = useSynthql(q);