diff --git a/docs/.vitepress/config.js b/docs/.vitepress/config.js index f37e1459f3..6ce15a3053 100644 --- a/docs/.vitepress/config.js +++ b/docs/.vitepress/config.js @@ -196,26 +196,6 @@ function getHomeSidebar() { text: "Postgres and S3", link: "/features/postgres-and-s3.html", }, - { - text: "Code generation", - link: "/features/code-gen-validators.html", - }, - { - text: "Code generation API", - link: "/features/code-gen-api.html", - }, - { - text: "Code generation API client", - link: "/features/code-gen-api-client.html", - }, - { - text: "Code generation SQL queries", - link: "/features/code-gen-sql.html", - }, - { - text: "Code generation CRUD", - link: "/features/code-gen-crud.html", - }, { text: "Postgres migrations", link: "/features/migrations.html", @@ -236,10 +216,6 @@ function getHomeSidebar() { text: "Extending the CLI", link: "/features/extending-the-cli.html", }, - { - text: "Route invalidations", - link: "/features/route-invalidations.html", - }, ], }, diff --git a/docs/features/code-gen-api-client.md b/docs/features/code-gen-api-client.md deleted file mode 100644 index 2310d18d3a..0000000000 --- a/docs/features/code-gen-api-client.md +++ /dev/null @@ -1,117 +0,0 @@ -# Code generator API client - -::: tip - -Requires `@compas/cli`, `@compas/stdlib`, `@compas/code-gen`, `Axios` and -(optionally) `react-query` to be installed. Also requires the project to be -using TypeScript. - -::: - -Compas can generate api clients from different sources & for different -consumers. We can read the OpenAPI specification, or get the Compas structure -from a remote Compas based api. We can generate only wrappers around Axios -calls, or provide full-fledged react-query hooks. - -## API functions - -Generating api functions from a remote structure consists of creating a -'generate' script, which reads either a remote api structure or an OpenAPI -definition and writes a bunch of TypeScript files to disk. It has the following -contents: - -```javascript -// scripts/generate.mjs -import { mainFn } from "@compas/stdlib"; -import { App, loadFromRemote } from "@compas/store"; -import axios from "axios"; - -mainFn(import.meta, main); - -async function main() { - const app = new App(); - - // Either: - // Load the OpenAPI 3.0 based definiotion somehow in to an object - const openApiDefinition = {}; - // 'serviceName' is used as the function name prefix if the used api definition is not using tags. - app.extendWithOpenApi("serviceName", openApiDefinition); - - // OR: - app.extend( - await loadFromRemote( - "https://api.example.com", // Base url of the Compas based api - axios.create({ - /* ... */ - }), - ), - ); - - // And finally - await app.generate({ - outputDirectory: "./src/generated", - isBrowser: true, - enabledGenerators: ["type", "apiClient"], - }); -} -``` - -After calling `node ./scripts/generate.mjs`, you should see some files in -`src/generated`. - -::: tip - -If the output of `node ./scripts/generate.mjs` consists of only lines with json, -add `NODE_ENV=development` to your `.env` or `.env.local` file. - -::: - -## Generated output - -There are a few cool files to look at; - -- `src/generated/common/types.ts`: this file contains all input and output types - used in the project. -- `src/generated/xxx/apiClient.ts`: these files contain the generated typed api - functions. As you can see it wraps the an `axios.request` call and returns the - response data. - -By using your IDE and requesting for autocomplete on `apiGroupName` you can see -all generated api functions. - -## Introducing react-query - -For projects with React, we also support automatically wrapping the generated -api calls in to react-query hooks, using `useQuery` and `useMutations`. To -enable the `react-query` generator add `"reactQuery"` to the `enabledGenerators` -array in `scripts/generate.mjs` and generate again via -`node ./scripts/generate.mjs`. - -This needs some setup to let the generated hooks know which Axios instance to -use. You can do that via the exported `ApiProvider` component, which should be -wrapped around the React component tree that uses generated hooks. - -Any api `GET` api call uses `useQuery` under the hood, and -`POST, PATCH, PUT & DELETE` calls use `useMutation` which are reflected in the -hooks arguments and return types. All hooks also have two special functions: - -- `useTodoSingle.baseKey()`: which returns the root query key without combining - any input parameters -- `useTodoSingle.queryKey({ /* expects all input params */ })`: for a specific - query key - -With these two you can do custom prefetching, invalidating and optimistic -updates. Invalidations can also be defined from the backend router. That is the -place where the designers should know which data are mutated and, in turn, which -routes should be invalidated. If added, mutation hooks will accept an extra -'hookOptions' object with the option to 'invalidateQueries'. This is off by -default. To check which queries are invalidated, look at the source of the -generated hook. - -`GET` requests have another speciality generated. An automatic -`options.enabled`. All required `param`, `query` and `body` parameters are used -to check if `options.enabled` should be set to true or false. This way we don't -do unnecessary network calls that we know will not succeed. - -As with the api functions, use autocomplete on `useGroupName` to discover the -generated functions. diff --git a/docs/features/code-gen-api.md b/docs/features/code-gen-api.md deleted file mode 100644 index d86bb48bb9..0000000000 --- a/docs/features/code-gen-api.md +++ /dev/null @@ -1,185 +0,0 @@ -# Code generator HTTP api - -The next code generators all resolve around creating and consuming structured -HTTP api's. If you are only interested in consuming Compas backed api's in -frontend projects, see -[code gen api client](/features/code-gen-api-client.html). - -::: tip - -Requires `@compas/cli`, `@compas/stdlib`, `@compas/server` and -`@compas/code-gen` to be installed. - -::: - -## Getting started - -In the [validator & type generator](/features/code-gen-validators.html) we have -seen how to utilize the Compas type system to generate types and validators. -Here we are going to get some good use out of them. We are going to create API -route definitions complete with integrated validators. - -The `TypeCreator (T)` also contains the entry point for the route system, with -`T.router(path)`. Let's define our first route: - -```js -const app = new App(); -const T = new TypeCreator("app"); -const R = T.router("/"); - -app.add(R.get("/hello", "hello").response(T.string())); -``` - -And add `"router"` to the `enabledGenerators` in our `app.generate()` call like -so: - -```js -await app.generate({ - outputDirectory: "./src/generated", - isNodeServer: true, - enabledGenerators: ["type", "validator", "router"], - dumpStructure: true, -}); -``` - -And execute the 'magic', `compas generate`. This created a bunch of new files, -so let's explain a few of them: - -- `src/generated/common/router.js`: this contains the full JavaScript route - matcher, and the router entrypoint `router(ctx, next)` so you can add it to - your Koa app. -- `src/generated/app/controller`: the file that contains the called controller - functions, like our `hello` route. - -Since all generated files will be overwritten, we need to implement the -controller in a new file. Create the follwing in `src/app/controller.js`: - -```js -import { appHandlers } from "../generated/app/controller.js"; - -appHandlers.hello = (ctx) => { - ctx.body = "Hello world!"; -}; -``` - -And finally we need to mount the generated router on our Koa instance in -`scripts/api.js`: - -```js -import { mainFn } from "@compas/stdlib"; -import { getApp } from "@compas/server"; -import { router } from "../src/generated/common/router.js"; - -async function main() { - const app = getApp(); - app.use(router); - - // Since we don't import our controllers any where, we need import them here to load our implementation. - // Else we would get '405 Not implemented' which is the default generated implementation. - await import("../src/app/controller.js"); - - app.listen(3000); -} -``` - -::: tip - -See [Http server](/features/http-server.html) for more details on `getApp`. - -::: - -Let's run the server and execute a request: - -```shell -compas api -curl http://localhost:3000/hello -``` - -## Query and path parameters - -Routes also support query and path parameters. Let's upgrade our `appHello` -route (group 'app', route 'hello') to accept a thing to say greet, and if the -greeting should be in uppercase. - -```js -app.add( - R.get("/hello/:thing", "helloToThing") - .params({ - things: T.string(), - }) - .query({ - // Note the convert. Query parameters are always strings, - // so to get and validate other primitives we need to enable conversion in the validators. - upperCase: T.bool().convert().default(false), - }) - .response(T.string()), -); -``` - -Let's regenerate first, so we get our validators and typings and then add the -controller implementation for `appHelloToThing` in `src/app/controller.js`: - -```js -appHandlers.helloToThing = (ctx) => { - let greeting = `Hello ${ctx.validatedParams.thing}!`; - - if (ctx.validatedQuery.upperCase) { - greeting = greeting.toUpperCase(); - } - - ctx.body = greeting; -}; -``` - -The last thing we need to do is add a parser so the query string is parsed and -thus can be validated. Change `srcipts/api.js` to: - -```js -import { mainFn } from "@compas/stdlib"; -import { getApp, createBodyParsers } from "@compas/server"; -import { router, setBodyParsers } from "../src/generated/common/router.js"; - -async function main() { - const app = getApp(); - - setBodyParsers(createBodyParsers()); - - app.use(router); - - // Since we don't import our controllers any where, we need import them here to load our implementation. - // Else we would get '405 Not implemented' which is the default generated implementation. - await import("../src/app/controller.js"); - - app.listen(3000); -} -``` - -Lets startup the api again and execute some requests: - -```shell -compas api -``` - -- `curl http://localhost:3000/hello/world`: As expected, results in a - `Hello world!` -- `curl http://localhost:3000/hello/world?upperCase=true`: Results in - `HELLO WORLD!`. Quite the greeting. -- `curl http://localhost:3000/hello/world?upperCase=5`: Oops a validator error. - The query parameters are automatically validated based on the provided - structure. -- `curl http://localhost:3000/hello/world/foo`: Path params match till the next - `/` or the end of route. So appending `/foo` results in a 404. -- `curl http://localhost:3000/hello/world/`: However trailing slashes are - allowed and ignored. - -## Advanced usages - -- [Route invalidations](/features/route-invalidations.html) - -[//]: # -[//]: # "## TODO:" -[//]: # -[//]: # "- Show other http methods & idempotent" -[//]: # "- Show tags" -[//]: # "- Show files upload & serving" -[//]: # diff --git a/docs/features/code-gen-crud.md b/docs/features/code-gen-crud.md deleted file mode 100644 index 0fe3a24c42..0000000000 --- a/docs/features/code-gen-crud.md +++ /dev/null @@ -1,195 +0,0 @@ -# Code generator CRUD - -Compas code-gen also supports generating CRUD routes. It combines the features -of the [api generator](/features/code-gen-api-client.html), -[sql generator](/features/code-gen-sql.html) and a generated implementation of -the necessary events. - -::: tip - -Requires `@compas/cli`, `@compas/stdlib`, `@compas/store` and `@compas/code-gen` -to be installed. - -::: - -## The features - -CRUD generation supports quite a variety of features and combinations there of. -Let's break them all down; - -### Route selection: - -Any CRUD declaration fully controls which routes are generated, by explicitly -enabling them. - -```js -const T = new TypeCreator("tag"); -const Tdatabase = new TypeCreator("database"); - -Tdatabase.object("tag") - .keys({ - key: T.string().searchable().trim().min(3), - value: T.string(), - }) - .enableQueries({ - withDates: true, - }); - -T.crud("/tag").entity(T.reference("database", "tag")).routes({ - listRoute: true, - singleRoute: true, - createRoute: true, - updateRoute: true, - deleteRoute: true, -}); -``` - -Combining all options, it generates the following routes; - -- `apiTagList` / `/tag/list` -- `apiTagSingle` / `/tag/:tagId/single` -- `apiTagCreate` / `/tag/create` -- `apiTagUpdate` / `/tag/:tagId/update` -- `apiTagDelete` / `/tag/:tagId/delete` - -### Filters, sorting and pagination: - -The generated `list` route comes fully equipped with filters, sorting and -pagination. The filters are a subset of the filters as supported by the query -builder, ie ignoring `$raw` and `$or` to prevent SQL injection and too complex -filters respectively. Sorting is also based on the current query builder -behaviour where you specify the columns to be sorted on in `orderBy` and a -seperated `orderBySpec` to determine the sort order for that column. And finally -pagination is supported via the `offset` and `limit` params. - -### Inline relations - -The CRUD generator can also include inline relations in the response, but also -allow creating and updating them via their respective routes. This works for -both `oneToMany` relations as for the referenced side of an `oneToOne` relation. -These inline relations can be added via `.inlineRelations()` like the following; - -```js -const T = new TypeCreator("post"); -const Tdatabase = new TypeCreator("database"); - -Tdatabase.object("post") - .keys({ - title: T.string().searchable(), - }) - .enableQueries({ - withDates: true, - }) - .relations(T.oneToMany("tags", T.reference("database", "tag"))); - -Tdatabase.object("tag") - .keys({ - key: T.string().searchable().trim().min(3), - value: T.string(), - }) - .enableQueries({ - withDates: true, - }) - .relations(T.manyToOne("post", T.reference("database", "post"), "tags")); - -T.crud("/post") - .entity(T.reference("database", "post")) - .routes({ - listRoute: true, - createRoute: true, - updateRoute: true, - }) - .inlineRelations(T.crud().fromParent("tags", { name: "tag" })); -``` - -The above generates approximately the following type for both the read routes, -like `apiPostList`, as well as for the write routes like `apiPostCrete` and -`apiPostUpdate`. - -``` -type PostItem = { - id: string; - title: string, - tags: { id: string, key: string, value: string }[] -} -``` - -When updating an inline relation, all existing values are first removed, before -the new values are added. `oneToOne` relations are mandatory by default, but can -be made optional via `T.crud().fromParent(...).optional()`. Inline relations can -be nested as many times as required. - -### Nested relations - -The same thing as above can be done but now with `.nestedRelations`. This -creates a nested route structure. - -```js -// Using the same Post -> tags[] relation like above - -T.crud("/post") - .entity(T.reference("database", "post")) - .routes({ - listRoute: true, - singleRoute: true, - createRoute: true, - updateRoute: true, - deleteRoute: true, - }) - .nestedRelations( - T.crud("/tag").fromParent("tags", { name: "tag" }).routes({ - // Routes need to be enabled - listRoute: true, - singleRoute: true, - createRoute: true, - updateRoute: true, - deleteRoute: true, - }), - ); -``` - -This generates the following routes: - -- `apiPostList` / `/post/list` -- `apiPostSingle` / `/post/:postId/single` -- `apiPostCreate` / `/post/create` -- `apiPostUpdate` / `/post/:postId/update` -- `apiPostDelete` / `/post/:postId/delete` -- `apiPostTagList` / `/post/:postId/taglist` -- `apiPostTagSingle` / `/post/:postId/tag/:tagId/single` -- `apiPostTagCreate` / `/post/:postId/tag/create` -- `apiPostTagUpdate` / `/post/:postId/tag/:tagId/update` -- `apiPostTagDelete` / `/post/:postId/tag/:tagId/delete` - -Appropriate route invalidations for react-query generator are automatically -added in all cases. In case a nested relation is used with a `oneToOne` -relation, the `list` route is automatically disabled, and the extra route params -are removed. So `/post/:postId/author/:authorId/single` is shortened to -`/post/:postId/author/single`. - -### Modifiers - -While calling `groupRegisterCrud` from the generated `crud.js` you can pass in -various 'modifiers'. These modifiers are all optional and can mutate the passed -in context, resolve a user, determine access control and edit the provided -'builders'. They are called after the static validation of params, query and -body, but before executing any other logic. - -All modifier functions can be async, getting an `event` as the first argument -and the request `ctx` as the second. The `single`, `update` and `delete` routes -also provide the used `builder` as the third argument. This way you can mutate -the executed where clause to prevent unauthorized access. - -The `list` route modifier passes in a `countBuilder` and a `listBuilder` as the -third and fourth argument respectively. By mutating both, the total stays in -sync with the returned values. Note that the result of the `count` event is used -to mutate the `listBuilder` afterwards to only select the results of the current -pagination result. - -Another edge case is the `create` event for a nested `oneToOne` relation. It's -third argument is the same builder as used in the `single` routes, for checking -if the relation already the `oneToOne` field. - -## TODO - -- fields readable, writable diff --git a/docs/features/code-gen-sql.md b/docs/features/code-gen-sql.md deleted file mode 100644 index 01454f0818..0000000000 --- a/docs/features/code-gen-sql.md +++ /dev/null @@ -1,655 +0,0 @@ -# Code generator SQL - -Compas code-gen also supports defining a relational schema. And is able to -generate all necessary queries for all common use cases. - -::: tip - -Requires `@compas/cli`, `@compas/stdlib`, `@compas/store` and `@compas/code-gen` -to be installed. - -::: - -## Getting started - -In the [validator & type generator](/features/code-gen-validators.html) we have -seen how to utilize the Compas type system to generate types and validators. -Here we are building a separate system. Most of the time your relational model -does not reflect the needs of your API consumers. To reflect that Compas advises -to keep the types defining a database schema separate from the rest. Compas also -does this by forcing the sql generator to output all it's files in a -`$outputDirectory/database` directory. - -## Defining the schema - -At the root, a database schema in Compas is a `T.object()` that defines some -keys, and calls `.enableQueries()` so it is picked up by the generator. - -Let's start with writing a database schema to represent a blog post. - -```js -const T = new TypeCreator("database"); - -app.add( - T.object("post") - .keys({ - title: T.string().searchable(), - body: T.string(), - isPublished: T.bool().searchable().default(false), - }) - .enableQueries({ - withPrimaryKey: true, // This is a default, and adds a `id: T.uuid()` to our keys. - withDates: true, // Add's a `createdAt` and `updatedAt` field for us - }), -); -``` - -Make sure to add `sql` to your generators and generate again. Take a look in -your generated directory and see what's generated. Some things include: - -- The `DatabasePost` type -- A new `database` directory containing a `post.js` that contains our queries -- An `queries` export in `database/index.js` that collects all our CRUD queries -- An example for the necessary Postgres DDL (ie `CREATE TABLE` queries) in - `common/structure.sql`. - -## CRUD - -The `queries` export from `database/index.js` contains typed CRUD related -queries. All values are automatically escaped to prevent injection attacks. -Let's take a look at them by example; - -**Inserts**: - -```js -// A single insert -const [post] = await queries.postInsert(sql, { - title: "My first post", - body: "...", -}); -// post => { id: "some uuid", title: "My first post", body: "...", isPublished: false, createdAt: ..., updatedAt: ... } - -// Multiple inserts -const posts = await queries.postInsert(sql, [ - { title: "Post1", body: "..." }, - { title: "Post2", body: "..." }, -]); -// posts => [{ id: "some uuid", title: "Post1", ... }, { id: "other uuid", title: "Post2", ... }] - -// With relation -const [category] = await queries.categoryInsert(sql, { name: "Category 1" }); -const [post] = await queries.postInsert(sql, { - title: "My post", - body: "...", - category: category.id, -}); -// post => { id: "some-uuid", title: "My post", category: "category.id uuid", ... } -``` - -**Updates**: - -```js -// Update single field, without returning the new row -await queries.postUpdate(sql, { - update: { isPublished: true }, - where: { id: post.id }, -}); - -// Update single field, return all fields -const [updatedPost] = await queries.postUpdate(sql, { - update: { isPublished: true }, - where: { id: post.id }, - returning: "*", -}); -// updatedPost => { id: post.id, title: post.title, isPublished: true } - -// Update fields, return some fields -const [updatedPost] = await queries.postUpdate(sql, { - update: { isPublished: true, title: "New post title" }, - where: { id: post.id }, - returning: ["id", "title"], -}); -// updatedPost => { id: post.id, title: "New post title" } -``` - -**Deletes**: - -```js -// Delete with the provided where clause -await quries.postDelete(sql, { - id: post.id, -}); -``` - -**Selects**: - -The missing part here are 'selects'. These are handled by `queryEntity` -functions exported from the `database/entity.js` files, in our case `queryPost` -in `database/post.js`. These are fully typed as well, and the input is validated -or escaped before it is transformed in to a query. The result of the query is -then transformed to conform to the types. The most important transformers being -converting `T.date()` columns to JS Date objects and `null` values to -`undefined`. - -```js -// Plain select -const posts = await queryPost().exec(sql); -// posts => [{ ... }, { ... }] - -// Select with where clause -const publishedPosts = await queryPost({ - where: { - isPublished: true, - }, -}).exec(sql); - -// Or with a limit and offset -const paginatedPosts = await queryPost({ - limit: 5, - offset: 10, -}).exec(sql); - -// Applying custom ordering -// `orderBy` is used to apply ordering in that order, -// and `orderBySpec` can be used to provide the sort specification. -const orderedResults = await queryPost({ - orderBy: ["title"], - orderBySpec: { title: "DESC" }, -}).exec(sql); -``` - -All above things can of course freely be combined. Another note here is that -`orderBy` and `where` are based on fields defined with `.searchable()` in the -Compas structure. This is done to make it more explicit what the main search -fields are of an entity and thus may be good candidates for PostgreSQL indices. - -## Relations and traversal - -A relational database is not useful if you can not have relations between -entities. Compas also supports the most common ways of modelling them and -provides a query builder to query an entity with its relations mapped in a -single Postgres query. - -Let's take a look at how that works, by creating a model with the following -entities and their relations; - -- Category entity - - Can be linked to Posts -- User entity - - Has optional settings - - Has written Posts -- UserSetting entity - - Belongs to User -- Post entity - - Written by User - - Can have linked Categories - -```js -const T = new TypeCreator("database"); - -app.add( - T.object("category") - .keys({ - /* ... */ - }) - .relations( - T.oneToMany("linkedPosts", T.reference("database", "postCategory")), - ) - .enableQueries({}), - - T.object("user") - .keys({ - /* ... */ - }) - .relations( - // 'Virtual' side of the relation - // 'posts' should be the same name as the last argument to the 'manyToOne' - T.oneToMany("posts", T.reference("database", "post")), - ) - .enableQueries({ - /* ... */ - }), - - T.object("userSettings") - .keys({ - /* ... */ - }) - .relations( - // Owning side of a one-to-one relation - // The 'virtual' side is automatically added - T.oneToOne("user", T.reference("database", "user"), "settings"), - ) - .enableQueries({}), - - T.object("post") - .keys({ - /* ... */ - }) - .relations( - // Owning side of the relation, a field is added named 'writer' - // which has the same type as the primary key of 'user'. - T.manyToOne("writer", T.reference("database", "user"), "posts"), - - T.oneToMany("linkedCategories", T.reference("database", "postCategory")), - ) - .enableQueries({ - /* ... */ - }), - - // Many-to-many relations need a join table, this is not automatically done by Compas - // The join table consists of two manyToOne relations - T.object("postCategory") - .keys({ - /* ... */ - }) - .relations( - T.manyToOne("post", T.reference("database", "user"), "linkedCategories"), - T.manyToOne( - "category", - T.reference("database", "category"), - "linkedPosts", - ), - ) - .enableQueries({}), -); -``` - -After regeneration, quite a bunch of code is added. See `common/structure.sql` -for how Compas suggests you to create the necessary entities and foreign keys. - -With all the information that you have added in the `.relations` calls, Compas -can create queries that join relations and nest the result set automatically. In -most of these example we use `[varName]`, this is for illustrative purposes -only, all calls will return an array with the results. Let's look at some -examples; - -**One-to-one**: - -```js -// Get user, but don't add join -const [user] = await queryUser({}).exec(sql); -// user => user.settings (undefined) - -// Get user settings with the user. -// 'settings' has an 'undefined' type, cause you can insert a user -// without inserting settings for them. -const [user] = await queryUser({ - settings: {}, -}).exec(sql); -// user => user.settings (undefined|DatabaseUserSettings) - -// Get settings, but don't join user. -// Since UserSettings is the owning side of the relation, -// the returned entity will have the 'id' from User. -const [userSettings] = await queryUserSettings({}).exec(sql); -// userSettings => userSettings.user (string, user.id) - -// Get the settings and the user -const [userSettings] = await queryUserSettings({ - user: {}, -}).exec(sql); -// userSettings => userSetting.user (DatabaseUser) -``` - -**Many-to-one**: - -From the owning side, this behaves the same as the 'One-to-one' owning side. - -```js -// Get post, but don't join writer -const [post] = await queryPost({}).exec(sql); -// post => post.writer (string, user.id); - -// Get post with the writer -const [post] = await queryPost({ - writer: {}, -}).exec(sql); -// post => post.writer (DatabaseUser) -``` - -**One-to-many**: - -```js -// Get user with posts -const [user] = await queryUser({ - posts: {}, -}).exec(sql); -// user => user.posts (DatabasePost[]) - -// Get post with categories. -// Need to traverse to many-to-one relations -const [post] = await queryPost({ - linkedCategories: { - category: {}, - }, -}).exec(sql); -// post => post.linkedCategories (DatabasePostCategory[]) -// post => post.linkedCategories[0].category (DatabaseCategory[]) -// post => post.linkedCategories[0].post (string, join is not added) -``` - -**Combined**: - -All relations can freely be combined. So you can query categories named 'sql' or -'code-gen' with all posts in the category and their writer like so: - -```js -const categories = await queryCategories({ - // Joins - linkedPosts: { - post: { - writer: {}, - }, - }, - - // Only query sql and code-gen categories - where: { - nameIn: ["sql", "code-gen"], - }, - - // Order by category.name ASC - orderBy: ["name"], - orderBySpec: { - name: "ASC", - }, -}); -``` - -## Where options - -All searchable fields and fields used in relations can be used in where clauses. -The values used in where-clauses are validated and escaped, so user input can be -used. - -```js -const users = await queryUser({ - where: { - name: "Jan", - }, -}).exec(sql); -// select * from "user" u WHERE u.name = 'Jan'; - -const users = await queryUser({ - where: { - ageGreaterThan: 18, - }, -}).exec(sql); -// select * from "user" u WHERE u.age > 18; - -const users = await queryUser({ - where: { - nameILike: "de Vries", - roleIn: ["moderator", "admin"], - }, -}).exec(sql); -// select * from "user" u WHERE u.name ILIKE '%de Vries%' AND role = ANY(ARRAY['moderator', 'admin']) - -const users = await queryUser({ - where: { - $or: [ - { - nameILike: "de Vries", - roleIn: ["moderator", "admin"], - }, - { - id: uuid(), - }, - ], - }, -}).exec(sql); -// select * from "user" u WHERE (u.name ILIKE '%de Vries%' AND role = ANY(ARRAY['moderator', 'admin'])) OR (u.id = 'uuid-value') - -const users = await queryUser({ - where: { - settingsNotExists: { - // nested where clause - }, - }, -}).exec(sql); -// select * from "user" u WHERE NOT EXISTS (select from "userSettings" us WHERE us.user = u.id); - -const users = await queryUser({ - where: { - // Useful for jsonb fields, or if field is not searchable - $raw: query`u."emailPreferences"->>'receiveNewsletter' = true`, - }, -}).exec(sql); -// select * from "user" u WHERE (u."emailPreferences"->>'receiveNewsletter' = true); -``` - -Another useful option provided by the where clause are the `viaXxx` options. -This allows you to query results from table `X` via their relation to table `Y`. -It results in queries that can span across over multiple tables to fetch results -with only a single piece of information that may not be immediately related to -what you need. For example: - -```js -const postsForUser = await queryPost({ - where: { - viaWriter: { - where: { - name: "Docs author", - }, - }, - }, -}).exec(sql); - -const categoriesThatUserHasPostIn = await queryCategory({ - where: { - viaLinkedPosts: { - where: { - viaPost: { - where: { - viaWriter: { - where: { - name: "Docs author", - }, - }, - }, - }, - }, - }, - }, -}); - -const dashboardsForAllGroupsThatAUserIsIn = await queryDashboard({ - where: { - // Owner is in this case a group - viaOwner: { - where: { - viaUsers: { - where: { - id: user.id, - }, - }, - }, - }, - }, -}).exec(sql); -``` - -## Atomic updates - -The generated update queries, can do a bit more than partial updates. Atomic -updates are supported as well. This way you can safely execute some operators on -the existing value, utilizing Postgres. This prevents race-conditions in your -code between the select of some value and the update of that value. - -Multiple atomic updates can be combined in the same update query, however, only -a single atomic update can be done per column. This is also enforced in the -types and validators. Let's look at some examples, based on the column type. - -**Booleans**: - -```js -// Flip the boolean value -await queries.jobUpdate(sql, { - update: { - isComplete: { - $negate: true, - }, - } /* ... */, -}); -``` - -**Numbers**: - -```js -// Add to the balance field -await queries.userUpdate(sql, { - update: { - balance: { - $add: 5, - }, - } /* ... */, -}); - -// Subtract from the balance field -await queries.userUpdate(sql, { - update: { - balance: { - $subtract: 5, - }, - } /* ... */, -}); - -// $multiply and $divide are supported as well -``` - -**Strings**: - -```js -// Flip the boolean value -await queries.userUpdate(sql, { - update: { - personalNotes: { - $append: "\nSome important addition.", - }, - } /* ... */, -}); -``` - -**Dates** - -This uses Postgres intervals, see the -[Postgres docs](https://www.postgresql.org/docs/current/functions-datetime.html) -for supported intervals - -```js -await queries.userUpdate(sql, { - update: { - licenseValidTill: { - $add: "1 year 2 months 3 hours", - }, - } /* ... */, -}); - -// Oops, conditions of our virtual buddy are not great -await queries.virtualBuddyUpdate(sql, { - update: { - virtualLifeExpectancy: { - $subtract: "2 hours 3 seconds", - }, - } /* ... */, -}); -``` - -**Jsonb**: - -These values are not thoroughly validated, so use with caution. `$set` uses -[jsonb_set](https://www.postgresql.org/docs/current/functions-json.html) -behavior. - -```js -// Disable email notifications of watched issues -await queries.userUpdate(sql, { - update: { - emailPreferences: { - $set: { - path: ["subscriptions", "watchedIssues"], - value: false, - }, - }, - } /* ... */, -}); - -// Remove all subscriptions -await queries.userUpdate(sql, { - update: { - emailPreferences: { - $remove: { - path: ["subscriptions"], - }, - }, - } /* ... */, -}); -``` - -## Date and time handling - -Compas uses `timestampt` for `T.date()` types. This ensures that you can insert -any date with timezone, and instruct Postgres to return them in whatever -timezone you want. Any query, except `queryBuilder.execRaw()`, will return -JavaScript Date objects. - -There is also `T.date().dateOnly()` which uses a Postgres `date` column. Compas -makes sure that the Postgres client doesn't convert these to dates, but instead -always handles them in the form of `YYYY-MM-DD` in selects, inserts and -where-clauses. `T.date().timeOnly()` works almost the same and uses a -`time without timezone` column. Inserts and where clauses can use -`HH:MM(:SS(.mmm))` strings, but selects are always returned as `HH:MM:SS(.mmm)`. - -To get this behaviour, Compas ensures that connections created via -`newPostgresConnection` from @compas/store, disable any conversion to JavaScript -Date objects for any `date` & `time` columns. - -## Soft deletes - -Compas also supports some form soft delete support via the `withSoftDeletes` -option passed to `.enableQueries()`. This option also enables `withDates` and -creates fields for `createdAt`, `updatedAt` and `deletedAt`. The generated -queries by default prevent you from querying soft deleted rows. To include those -you need to pass `deletedAtIncludeNotNull` in the where clause. Try to minimize -the use of this future, as it can grow complex quite fast. Especially if -multiple entities can be soft deleted apart from each other. - -```js -// Create entity -const [entity] = await queries.entityInsert(sq, { - /* ... */ -}); -const [selectedEntity] = await queryEntity({ - where: { id: entity.id }, -}).exec(sql); - -// Soft delete entity -// This also supports setting a date in the future, for when it should be soft deleted. -// A soft delete can also be removed by providing `{ update: { deletedAt: null }, where: { id: entity.id, deletedAtIncludeNotNull } }` -await queries.entityUpdate(sql, { - update: { deletedAt: new Date() }, - where: { id: entity.id }, -}); - -// No result, since it is soft deleted -// If a `deletedAt` is set in the future, it will still be returned here, till the set date is passed. -await queryEntity({ - where: { id: entity.id }, -}).exec(sql); - -const [softDeletedEntity] = await queryEntity({ - where: { - id: entity.id, // Supported everywhere, where a `where` is accepted. - deletedAtIncludeNotNull: true, - }, -}).exec(sql); - -// Hard deletes always add `where.deletedAtIncludeNotNull = true` -await queries.entityDelete(sql, { id: entity.id }); -``` - -## Other constraints - -The sql generator has quite a few constraints and checks that it checks before -generating any code. - -// TODO: reference them and their solutions diff --git a/docs/features/code-gen-validators.md b/docs/features/code-gen-validators.md deleted file mode 100644 index 06e680bf51..0000000000 --- a/docs/features/code-gen-validators.md +++ /dev/null @@ -1,366 +0,0 @@ -# Code generators - -::: tip - -Requires `@compas/cli`, `@compas/stdlib` and `@compas/code-gen` to be installed - -::: - -Compas provides various code generators solving two main things: - -- Provide a contract between backend and frontends -- Generate typed backend basics like routers, validators and CRUD queries - -This document contains the setup and looks at the different types and generated -validators while the next documents are diving in for example the router and api -clients. - -## Setup - -Let's start by creating a new script in `scripts/generate.js` with the following -contents: - -```js -import { mainFn } from "@compas/stdlib"; -import { App } from "@compas/code-gen"; - -mainFn(import.meta, main); - -async function main() { - const app = new App(); - - await app.generate({ - outputDirectory: "./src/generated", - isNodeServer: true, - enabledGenerators: ["type"], - dumpStructure: true, - }); -} -``` - -It creates a new `App` instance which is the code generator entrypoint. After -that we directly call `app.generate`, this is where the magic happens, and the -output files are written. If you execute `compas generate` a few files should -have been created in `src/generated`: - -- `common/structure.js`: All information known to the code generators - serialized. This way you can for example regenerate without knowing the - original input. This file is controlled by the `dumpStructure` and - `dumpApiStructure` options. -- `common/types.d.ts`: This file will contain the generated types that we need, - is controlled by `enabledGenerators: ["type"]`. - -## Adding types - -Since our setup works now, we can add some types. For this we need to import the -`TypeCreator` from `@compas/code-gen` and create an instance: -`const T = new TypeCreator("todo")`. We pass in `"todo"` as an argument to the -`TypeCreator` to name our collection of types. Each item or type in the code -generators has a 'group', in this case `"todo"` and a name, which we will come -by shortly. The default 'group' name, if not specified, is `"app"`. We also use -`T` as the variable name as a short abbreviation, and would be recommended to -keep as a convention in your projects. - -Know that we have a `TypeCreator` we can create some types. - -```js -import { mainFn } from "@compas/stdlib"; -import { App, TypeCreator } from "@compas/code-gen"; - -mainFn(import.meta, main); - -async function main() { - const app = new App(); - const T = new TypeCreator("todo"); - - app.add( - // `"item"` is the type 'name', all types added to `app` should have a name. - T.object("item").keys({ - id: T.uuid(), - title: T.string(), - createdAt: T.date(), - isFinished: T.bool().optional(), - }), - ); - - await app.generate({ - outputDirectory: "./src/generated", - isNodeServer: true, - enabledGenerators: ["type"], - dumpStructure: true, - }); -} -``` - -On the `T` (`TypeCreator`) we have a bunch of 'type' methods. These mostly -correspond to the equivalent JavaScript and TypeScript types. Let's check that -out, but first regenerate with `compas generate`. - -Our `common/types.d.ts` now contains some relevant types for us, a `TodoItem` -(consisting of the group name (`todo`) and the type name (`item`) as the unique -name `TodoItem`): - -```typescript -type TodoItem = { - id: string; - title: string; - createdAt: Date; - isFinished?: undefined | boolean; -}; -``` - -## Validators - -Well you say: 'This ain't fancy, I need to learn a specific DSL just to generate -some TypeScript types that I can write by hand.'. And you would be right if -types where the only things Compas could generate for you. So let's do something -more useful and add validators in to the mix. We can enable validators by adding -`validator` to our `enabledGenerators` option like so: - -```js -await app.generate({ - outputDirectory: "./src/generated", - isNodeServer: true, - enabledGenerators: ["type", "validator"], - dumpStructure: true, -}); -``` - -And let's generate again with `compas generate`. This added a few more files: - -- `common/anonymous-validators.js`: pure JavaScript validator code internally - used for all validators in you project, this file can get huge if your project - grows. -- `todo/validators.js`: The generated `validateTodoItem` function. It used the - anonymous validators from `common/anonymous-validators.js` to check the input. - -Let's do a quick check if our validators are up to something: - -```js -// scripts/validator-test.js -import { validateTodoItem } from "../src/generated/todo/validators.js"; -import { mainFn, uuid } from "@compas/stdlib"; - -mainFn(import.meta, main); - -function main(logger) { - // A success result - logger.info( - validateTodoItem({ - id: uuid(), - title: "Finish reading Compas documentation", - createdAt: new Date(), // We can leave out 'isFinished' since it is `.optional()` - }), - ); - - // And a validation error - logger.info( - validateTodoItem({ - title: "Finish reading Compas documentation", - createdAt: new Date(), - isFinished: "true", - }), - ); -} -``` - -And check if the validators are doing what they should with -`compas validator-test`. Which should output something like: - -```txt -/* ... */ { - value: [Object: null prototype] { - id: '114531fb-810d-45cf-819a-856892972acd', - title: 'Finish reading Compas documentation', - createdAt: 2021-09-19T09:32:11.359Z, - isFinished: undefined - } -} -/* ... */ { - error: { - key: 'validator.error', - status: 400, - info: { - '$.id': { - propertyPath: '$.id', - key: 'validator.uuid.undefined', - info: {} - }, - '$.isFinished': { - propertyPath: '$.isFinished', - key: 'validator.boolean.type', - info: {} - } - }, - stack: [ - /** ... */ - ], - } -} -``` - -As you can see, the validators either return a `{ value: ... }` or -`{ error: ... }` object. The first being a `{ value: ... }` since the input -object complied with our structure. The second result is more interesting, as it -is an `{ error: ... }` result. It tells us that something is wrong in the -validators (`key: "validator.error"`) and tells us the two places where our -input is incorrect: - -- `$.id`: From the input root (`$`), pick the `id` property. We expect an uuid - (the first part of our key `validator.uuid`), but it is undefined. -- `$.isFinished`: From the input root, pick the `isFinished` property. We expect - a boolean (`validator.boolean`), but we got the incorrect type (in this case a - string). - -::: tip - -Make sure to have a `.env` file with `NODE_ENV=development` in it for local -development so log lines are readable. - -::: - -We can also add some type specific validators in to the mix, for example our -'TodoItem' title should be at least 10 characters, and the `isFinished` property -should also accept `"true","false"` strings as well as `true` and `false` -booleans. - -```js -// In scripts/generate.js -app.add( - // `"item"` is the type 'name', all types added to `app` should have a name. - T.object("item").keys({ - id: T.uuid(), - title: T.string().min(10), - createdAt: T.date(), - isFinished: T.bool().optional().convert(), - }), -); -``` - -And to check our outputs replace `scripts/validator-test.js` with the following: - -```js -// scripts/validator-test.js -import { validateTodoItem } from "../src/generated/todo/validators.js"; -import { mainFn, uuid } from "@compas/stdlib"; - -mainFn(import.meta, main); - -function main(logger) { - // A success result - logger.info( - validateTodoItem({ - id: uuid(), - title: "Finish reading Compas documentation", - createdAt: new Date(), - isFinished: "false", - }), - ); - - // And a validation error - logger.info( - validateTodoItem({ - id: uuid(), - title: "Too short", // 9 characters - createdAt: new Date(), - }), - ); -} -``` - -Regenerate with `compas generate` and run the validators with -`compas validator-test`, which yields the following: - -```txt -/* ... */ { - value: [Object: null prototype] { - id: '5f1d04c9-2e20-4b76-9720-b699b543978e', - title: 'Finish reading Compas documentation', - createdAt: 2021-09-19T09:55:37.073Z, - isFinished: false - } -} -/* ... */ { - error: { - key: 'validator.error', - status: 400, - info: { - '$.title': { - propertyPath: '$.title', - key: 'validator.string.min', - info: { min: 10 } - } - }, - stack: [ - /* ... */ - ], - } -} -``` - -As you can see, the `isFinshed` property of the first validator call is accepted -and converted to the `false` value. And the error from the second validate call -now contains our new validator: - -- `$.title`: The title property does not confirm the `validator.string.min` - validator. And it also returns what the minimum length is via `info->min`. - -## More types and validators - -Compas code generators include a bunch more types and type specific validators. -The following list is not completely exhaustive but should give a general idea -about what to expect. Note that all validators can be combined, eg -`T.number().convert().optional().min(3).max(10)`, which optionally accepts an -integer between 3 and 10 as either a number literal or a string that can be -converted to an integer between 3 and 10` - -**boolean**: - -| Type | Input | Output | -| -------------------- | -------------- | -------------------- | -| T.bool() | true/false | true/false | -| T.bool().oneOf(true) | true | true | -| T.bool().oneOf(true) | false | validator.bool.oneOf | -| T.bool().convert() | "true"/0/false | true/false/false | - -**number**: - -| Type | Input | Output | -| ----------------------------- | ----- | ------------------------ | -| T.number() | 34 | 34 | -| T.number() | 34.15 | validator.number.integer | -| T.number().float() | 34.15 | 34.15 | -| T.number().convert() | "15" | 15 | -| T.number().min(5) | 2 | validator.number.min | -| T.number().oneOf(30, 50, 100) | 30 | 30 | -| T.number().oneOf(30, 50, 100) | 60 | validator.number.oneOf | - -**string**: - -| Type | Input | Output | -| ---------------------------------- | --------- | -------------------------- | -| T.string() | "foo" | "foo" | -| T.string() | undefined | validator.string.undefined | -| T.string() | null | validator.string.undefined | -| T.string().optional() | undefined | undefined | -| T.string().optional() | null | undefined | -| T.string().allowNull() | undefined | undefined | -| T.string().allowNull() | null | null | -| T.string().max(3) | "Yess!" | validator.string.max | -| T.string().upperCase() | "Ja" | "JA" | -| T.string().oneOf("NORTH", "SOUTH") | "NORTH" | "NORTH" | -| T.string().oneOf("NORTH", "SOUTH") | "WEST" | validator.string.oneOf | -| T.string().pattern(/\d+/g) | "foo" | validator.string.pattern | - -**date**: - -| Type | Input | Output | -| ------------------- | ---------------------------------- | ------------------------ | -| T.date() | Any input accepted by `new Date()` | Date | -| T.date().dateOnly() | "2020-01-01" | "2020-01-01" | -| T.date().dateOnly() | "2020-01" | validator.string.min | -| T.date().dateOnly() | "2020-01001" | validator.string.pattern | -| T.date().timeOnly() | "20:59" | "20:59" | -| T.date().timeOnly() | "24:59" | validator.string.pattern | -| T.date().timeOnly() | "10:10:10" | "10:10:10" | -| T.date().timeOnly() | "10:10:10.123" | "10:10:10.123" | diff --git a/docs/features/http-server.md b/docs/features/http-server.md index 8d6f31a0d6..b3ffaac6a5 100644 --- a/docs/features/http-server.md +++ b/docs/features/http-server.md @@ -30,7 +30,7 @@ async function main() { When we run this file we can already check out some default features. ```shell -compas api +compas run api # Or node ./scripts/api.js ``` diff --git a/docs/features/route-invalidations.md b/docs/features/route-invalidations.md deleted file mode 100644 index 98096bf726..0000000000 --- a/docs/features/route-invalidations.md +++ /dev/null @@ -1,121 +0,0 @@ -# Route invalidations - -::: tip - -Requires an understanding of the code generated api and api clients. See -[Code generator HTTP api](/features/code-gen-api.html) for more information. - -::: - -The Compas structure also allows for defining route invalidations. Route -invalidations are a typed and validated way for specifying which route data are -altered on a successful call of a POST, PUT, PATCH or DELETE route. For most -generators, like the `router`, `validator` and `apiClient` this is a noop. But -for caching api clients, like react-query (via the `reactQuery` generator), this -achieves a way to invalidate the cache for all routes, which data has changed as -a result of a successful mutation, with the toggle of an option. - -## Structure - -The structure is defined via a `.invalidations` function on routes. Let's take a -look at some examples; - -```js -const T = new TypeCreator("app"); -const R = T.router("/app"); - -app.add( - // Example get routes - R.get("/list", "list").response({}), - R.get("/:id", "get").params({ id: T.uuid() }).response({}), - - // For operations that mutate all responses in this group, just invalidate the whole group. - R.post("/shuffle", "shuffle") - .response({}) - .invalidations(R.invalidates("app")), - - // For operations invalidating a specific route. - R.post("/", "create") - .body({}) - .response({}) - .invalidations(R.invalidates("app", "list")), - - // Invalidate multiple routes, - // Both this update route and `AppGet` define a `id` param, so we can use `useSharedParams` to only invalidate the get route of this specific entity. - R.put("/:id", "update") - .params({ id: T.uuid() }) - .response({}) - .invalidations( - R.invalidates("app", "list"), - R.invalidates("app", "get", { useSharedParams: true }), - ), - - // Provide a specification to map properties. - R.post("/toggle", "toggle") - .body({ - id: T.uuid(), - }) - .response({}) - .invalidations( - R.invalidates("app", "get", { - specification: { - params: { - id: ["body", "id"], - }, - }, - }), - ), -); -``` - -All above examples can be mixed and matched, and the generator will guide you in -the right direction if some invalidation is invalid. - -- `useSharedParams` and `useQueryParams` are shorthand properties for populating - the `specification`. They extract the shared properties of the source and - target route and build up the specification. Existing `specification` - properties take precedence over properties that would be defined because of - `useSharedParams` or `useSharedQuery`. -- The `specification` object is a way of specifying how the target `params` and - `query` object look like. The arrays of strings define an 'object path' for - which values of the current route to use. - -## Usage - -When this definition is used with the `reactQuery` generator, Compas generates -something like the below snippet with based on the above defined -`R.put("/:id", "update")`: - -```tsx -export function useAppUpdate( - options: UseMutationOptions = {}, - hookOptions: { invalidateQueries?: boolean } = {}, -): UseMutationResult { - // ... setup - - if (hookOptions?.invalidateQueries) { - const originalOnSuccess = options.onSuccess; - - options.onSuccess = async (data, variables, context) => { - queryClient.invalidateQueries(["app", "list"]); - queryClient.invalidateQueries([ - "app", - "get", - { id: variables.params.id }, - ]); - - if (typeof originalOnSuccess === "function") { - return await originalOnSuccess(data, variables, context); - } - }; - } - - // ... call useMutation -} -``` - -As you can see, Compas does not call the invalidations by default, -`hookOptions.invalidateQueries` has to be truthy for that to happen. It will -also handle and call the `onSuccess` option if defined. Read the -[react-query docs](https://react-query.tanstack.com/guides/query-invalidation#_top) -about Query invalidation for more information. diff --git a/docs/features/test-and-bench-runner.md b/docs/features/test-and-bench-runner.md index 3198cffbfd..b52ccae053 100644 --- a/docs/features/test-and-bench-runner.md +++ b/docs/features/test-and-bench-runner.md @@ -299,8 +299,8 @@ Compare `value` and `expected` using the Node.js built-in There are two ways to run tests: - `compas test` runs all tests in files where the name ends with `.test.js` -- `compas ./path/to/file.test.js` runs a single test file, provided that the - file calls `mainTestFn(import.meta)`. +- `compas run ./path/to/file.test.js` runs a single test file, if the file calls + `mainTestFn(import.meta)`. Whe running all tests, Compas will automatically utilize [worker threads](https://nodejs.org/api/worker_threads.html) to do some parallel diff --git a/docs/features/typescript-setup.md b/docs/features/typescript-setup.md index 31dd3e2586..bd8ae160a0 100644 --- a/docs/features/typescript-setup.md +++ b/docs/features/typescript-setup.md @@ -8,19 +8,9 @@ Requires `@compas/cli` to be installed The recommended way of developing projects with Compas is to use JavaScript with types in JSDoc. This prevents compilation steps and gives the same auto complete -experience as Typescript. Some IDE's don't work like this so we added and now -recommend using a jsconfig so the Typescript Language server understands Compas -projects. Use `compas init --jsconfig` to create / overwrite the recommended -config. +experience as with Typescript. Some IDE's work better with the Typescript +Language Server so we recommend using a `tsconfig` or `jsconfig`. Compas +provides a `jsconfig` via `compas init --jsconfig`. -Note that the ESLint setup doesn't support Typescript files, so make sure to -exclude them if your IDE has ESLint integration. - -## Code generation - -Frontend projects using the code generators are expected to use Typescript, -which is why the 'reactQuery' generator only supports generating Typescript. - -The code generator also supports making all major Compas types global, via a -generated `.d.ts` (Typescript declaration) file. To use this, see -[generateTypes](/index.html#todo) +Note that the Compas ESLint plugin does not support Typescript files. Use a +custom config with for example `@typescript-eslint` for a better experience. diff --git a/docs/generators/usage/js-postgres.md b/docs/generators/usage/js-postgres.md new file mode 100644 index 0000000000..66472820d0 --- /dev/null +++ b/docs/generators/usage/js-postgres.md @@ -0,0 +1,18 @@ +# Node.js Postgres + +::: details + +- insert, multi-insert + - return values +- updates + - with where clause + - custom returning +- delete + - only hard deletes +- selects + - orderBy, where, + - relations +- Where traversal +- Atomic updates + +:::